feat(vfx): port retail hidden and teleport presentation

Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
This commit is contained in:
Erik 2026-07-14 14:59:48 +02:00
parent a51ebc66e9
commit 1e98d81448
46 changed files with 4883 additions and 127 deletions

View file

@ -88,6 +88,38 @@ public sealed class AttachmentUpdateOrderTests
Assert.Equal(new Vector3(100f, 2f, 0f), renderedOrigin, new Vector3ToleranceComparer());
}
[Fact]
public void NestedDrawable_InheritsAncestorSuppressionWithoutMutatingOwnState()
{
var root = Entity(1u);
var child = Entity(2u);
var grandchild = Entity(3u);
root.IsDrawVisible = false;
EquippedChildRenderController.ApplyParentDrawVisibility(child, root);
EquippedChildRenderController.ApplyParentDrawVisibility(grandchild, child);
Assert.True(child.IsDrawVisible);
Assert.False(child.IsAncestorDrawVisible);
Assert.True(grandchild.IsDrawVisible);
Assert.False(grandchild.IsAncestorDrawVisible);
root.IsDrawVisible = true;
EquippedChildRenderController.ApplyParentDrawVisibility(child, root);
EquippedChildRenderController.ApplyParentDrawVisibility(grandchild, child);
Assert.True(child.IsAncestorDrawVisible);
Assert.True(grandchild.IsAncestorDrawVisible);
}
private static AcDream.Core.World.WorldEntity Entity(uint id) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
};
[Fact]
public void AncestorTeardownCollectsEntireAttachmentSubtreePostOrder()
{

View file

@ -0,0 +1,65 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Tests.Rendering;
public sealed class EntityPhysicsHostTests
{
[Fact]
public void NotifyHidden_FansNonOkStatusAndClearsWatcherSubscription()
{
const uint watcherId = 0x50000001u;
const uint targetId = 0x70000001u;
var hosts = new Dictionary<uint, IPhysicsObjHost>();
var watcherUpdates = new List<TargetInfo>();
EntityPhysicsHost watcher = CreateHost(
watcherId,
hosts,
watcherUpdates.Add);
EntityPhysicsHost target = CreateHost(targetId, hosts, _ => { });
hosts.Add(watcherId, watcher);
hosts.Add(targetId, target);
watcher.SetTarget(0u, targetId, 1f, 0.1);
watcherUpdates.Clear();
target.NotifyHidden();
TargetInfo hidden = Assert.Single(watcherUpdates);
Assert.Equal(TargetStatus.ExitWorld, hidden.Status);
Assert.Null(watcher.TargetManager.TargetInfo);
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(watcherId));
watcherUpdates.Clear();
watcher.SetTarget(0u, targetId, 1f, 0.1);
TargetInfo restored = Assert.Single(watcherUpdates);
Assert.Equal(TargetStatus.Ok, restored.Status);
Assert.Equal(targetId, restored.ObjectId);
Assert.True(target.TargetManager.VoyeurTable.ContainsKey(watcherId));
}
private static EntityPhysicsHost CreateHost(
uint id,
IReadOnlyDictionary<uint, IPhysicsObjHost> hosts,
Action<TargetInfo> update)
{
return new EntityPhysicsHost(
id,
getPosition: () => new Position(
0x01010001u,
new Vector3(id == 0x50000001u ? 0f : 2f, 0f, 0f),
Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 1.0,
physicsTimerTime: () => 1.0,
getObjectA: objectId => hosts.GetValueOrDefault(objectId),
handleUpdateTarget: update,
interruptCurrentMovement: () => { });
}
}

View file

@ -166,6 +166,28 @@ public sealed class EntityEffectControllerTests
Assert.DoesNotContain(fixture.Sink.Calls, call => call.EntityId == Guid);
}
[Fact]
public void PreparedOwner_DoesNotReplayPendingPacketsUntilConstructionBarrierOpens()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
WorldSession.EntitySpawn spawn = Spawn(Guid, 1);
fixture.Runtime.RegisterLiveEntity(spawn);
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
fixture.Runtime.MaterializeLiveEntity(
Guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, Guid));
Assert.True(fixture.Controller.PrepareLiveEntityOwner(Guid));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Controller.ReplayPendingForLiveEntity(Guid));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
}
[Fact]
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
{