fix(world): retire motion links at hidden transitions

Port CPhysicsObj::set_hidden's PartArray HandleEnterWorld boundary on both Hidden and UnHide. This strips linked recall/casting animations and aborts their pending completions before cell visibility changes, preventing spell-recall tails after portal travel without arrival resets or action classifiers.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-16 07:39:34 +02:00
parent 75acae02d6
commit bfb4b26dff
6 changed files with 185 additions and 21 deletions

View file

@ -1,11 +1,15 @@
using System.Numerics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Content.Vfx;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit.Sdk;
namespace AcDream.App.Tests.World;
@ -36,6 +40,11 @@ public sealed class LiveEntityPresentationControllerTests
fixture.TypedPlays);
Assert.Equal([(Fixture.Guid, true)], fixture.ChildNoDraw);
Assert.Equal([Fixture.Guid], fixture.InvalidTargets);
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
Assert.Equal([0], fixture.PartArrayShadowCounts);
Assert.Equal(
["effect:76", "children:True", "part-array", "target"],
fixture.PresentationOrder);
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.False(fixture.Entity.IsDrawVisible);
@ -52,11 +61,87 @@ public sealed class LiveEntityPresentationControllerTests
],
fixture.TypedPlays);
Assert.Equal([(Fixture.Guid, true), (Fixture.Guid, false)], fixture.ChildNoDraw);
Assert.Equal(
[fixture.Entity.Id, fixture.Entity.Id],
fixture.PartArrayEnterWorld);
Assert.Equal([0, 0], fixture.PartArrayShadowCounts);
Assert.Equal(
[
"effect:76", "children:True", "part-array", "target",
"effect:75", "children:False", "part-array",
],
fixture.PresentationOrder);
Assert.Equal(1, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Entity.IsDrawVisible);
Assert.Same(fixture.Entity, Assert.Single(fixture.Runtime.WorldEntities).Value);
}
[Fact]
public void SpellRecall_HiddenAndUnHide_RetireMagicTimelineThroughController()
{
const uint humanSetup = 0x02000001u;
const uint humanMotionTable = 0x09000001u;
const uint magic = 0x80000049u;
const uint ready = 0x41000003u;
const uint magicPortal = 0x40000038u;
string datDir = @"C:\Turbine\Asheron's Call";
if (!File.Exists(Path.Combine(datDir, "client_portal.dat")))
throw SkipException.ForSkip("Installed retail DATs are required.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(humanSetup));
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(humanMotionTable));
var sequencer = new AnimationSequencer(
setup,
table,
new RetailAnimationLoader(dats));
var fixture = new Fixture(
PhysicsStateFlags.ReportCollisions,
_ => sequencer.Manager.HandleEnterWorld());
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
sequencer.SetCycle(magic, ready);
sequencer.PlayAction(magicPortal);
Assert.False(sequencer.CurrentNodeDiag.IsLooping);
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(
Fixture.Guid,
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
1,
2),
out _,
out _));
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
Assert.True(sequencer.CurrentNodeDiag.IsLooping);
Assert.Empty(sequencer.Manager.PendingAnimations);
// A motion accepted while the mesh is hidden is retired again at
// retail's matching UnHide PartArray boundary before cell visibility
// returns.
var unhideSequencer = new AnimationSequencer(
setup,
table,
new RetailAnimationLoader(dats));
var unhideFixture = new Fixture(
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions,
_ => unhideSequencer.Manager.HandleEnterWorld());
Assert.True(unhideFixture.Controller.OnLiveEntityReady(Fixture.Guid));
unhideSequencer.SetCycle(magic, ready);
unhideSequencer.PlayAction(magicPortal);
Assert.False(unhideSequencer.CurrentNodeDiag.IsLooping);
Assert.True(unhideFixture.Runtime.TryApplyState(
new SetState.Parsed(Fixture.Guid, 0u, 1, 3),
out _,
out _));
Assert.True(unhideFixture.Controller.OnStateAccepted(Fixture.Guid));
Assert.True(unhideSequencer.CurrentNodeDiag.IsLooping);
Assert.Empty(unhideSequencer.Manager.PendingAnimations);
Assert.Equal([0], fixture.PartArrayShadowCounts);
Assert.Equal([0, 0], unhideFixture.PartArrayShadowCounts);
Assert.Equal(1, unhideFixture.Shadows.TotalRegistered);
}
[Fact]
public void IdenticalOrStaleState_DoesNotReplayHiddenEffect()
{
@ -76,6 +161,7 @@ public sealed class LiveEntityPresentationControllerTests
Assert.Single(fixture.TypedPlays);
Assert.Equal(LiveEntityPresentationController.HiddenScriptType,
fixture.TypedPlays[0].Type);
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
}
[Fact]
@ -166,13 +252,18 @@ public sealed class LiveEntityPresentationControllerTests
public List<(uint Owner, uint Type, float Intensity)> TypedPlays { get; } = [];
public List<(uint Parent, bool NoDraw)> ChildNoDraw { get; } = [];
public List<uint> InvalidTargets { get; } = [];
public List<uint> PartArrayEnterWorld { get; } = [];
public List<int> PartArrayShadowCounts { get; } = [];
public List<string> PresentationOrder { get; } = [];
public GpuWorldState Spatial { get; }
public LiveEntityRuntime Runtime { get; }
public ShadowObjectRegistry Shadows { get; } = new();
public WorldEntity Entity { get; }
public LiveEntityPresentationController Controller { get; }
public Fixture(PhysicsStateFlags initialState)
public Fixture(
PhysicsStateFlags initialState,
Action<uint>? onPartArrayEnterWorld = null)
{
Spatial = new GpuWorldState();
Spatial.AddLandblock(new LoadedLandblock(
@ -214,11 +305,27 @@ public sealed class LiveEntityPresentationControllerTests
(owner, type, intensity) =>
{
TypedPlays.Add((owner, type, intensity));
PresentationOrder.Add($"effect:{type:X2}");
return true;
},
(parent, noDraw) => ChildNoDraw.Add((parent, noDraw)),
InvalidTargets.Add,
() => (1, 1));
(parent, noDraw) =>
{
ChildNoDraw.Add((parent, noDraw));
PresentationOrder.Add($"children:{noDraw}");
},
guid =>
{
InvalidTargets.Add(guid);
PresentationOrder.Add("target");
},
() => (1, 1),
handlePartArrayEnterWorld: localEntityId =>
{
PartArrayEnterWorld.Add(localEntityId);
PartArrayShadowCounts.Add(Shadows.TotalRegistered);
PresentationOrder.Add("part-array");
onPartArrayEnterWorld?.Invoke(localEntityId);
});
}
internal static WorldSession.EntitySpawn Spawn(