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>
380 lines
15 KiB
C#
380 lines
15 KiB
C#
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;
|
|
|
|
public sealed class LiveEntityPresentationControllerTests
|
|
{
|
|
[Fact]
|
|
public void InitialVisibleObject_DoesNotPlayUnHide()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
|
|
Assert.Empty(fixture.TypedPlays);
|
|
Assert.True(fixture.Entity.IsDrawVisible);
|
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
|
}
|
|
|
|
[Fact]
|
|
public void HiddenThenVisible_PlaysTypedEffectsMutatesChildrenAndRestoresShadow()
|
|
{
|
|
Fixture fixture = new(
|
|
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions);
|
|
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
|
|
Assert.Equal(
|
|
[(fixture.Entity.Id, LiveEntityPresentationController.HiddenScriptType, 1f)],
|
|
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);
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(Fixture.Guid, 0u, 1, 2),
|
|
out _,
|
|
out _));
|
|
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
|
|
|
Assert.Equal(
|
|
[
|
|
(fixture.Entity.Id, LiveEntityPresentationController.HiddenScriptType, 1f),
|
|
(fixture.Entity.Id, LiveEntityPresentationController.UnHideScriptType, 1f),
|
|
],
|
|
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()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
fixture.Controller.OnLiveEntityReady(Fixture.Guid);
|
|
var hidden = new SetState.Parsed(
|
|
Fixture.Guid,
|
|
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
|
|
1,
|
|
2);
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(hidden, out _, out _));
|
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
|
Assert.False(fixture.Runtime.TryApplyState(hidden, out _, out _));
|
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
|
|
|
Assert.Single(fixture.TypedPlays);
|
|
Assert.Equal(LiveEntityPresentationController.HiddenScriptType,
|
|
fixture.TypedPlays[0].Type);
|
|
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnHideWhileLandblockPending_RestoresShadowWhenProjectionReturns()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
fixture.Controller.OnLiveEntityReady(Fixture.Guid);
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Fixture.Guid,
|
|
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
|
|
1,
|
|
2),
|
|
out _,
|
|
out _));
|
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
|
|
|
Assert.True(fixture.Runtime.RebucketLiveEntity(Fixture.Guid, 0x02020001u));
|
|
Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
|
|
|
|
Assert.True(fixture.Runtime.TryApplyState(
|
|
new SetState.Parsed(Fixture.Guid, 0u, 1, 3),
|
|
out _,
|
|
out _));
|
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
|
|
|
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
|
0x0202FFFFu,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
|
|
Assert.True(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
|
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset()
|
|
{
|
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
Assert.True(fixture.Controller.BeginAuthoritativePlacement(Fixture.Guid, 1));
|
|
Assert.True(fixture.Controller.HasActivePlacement(Fixture.Guid));
|
|
Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out LiveEntityRecord oldRecord));
|
|
|
|
fixture.Controller.Forget(oldRecord);
|
|
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(Fixture.Guid, 1),
|
|
isLocalPlayer: false));
|
|
Assert.False(fixture.Controller.HasActivePlacement(Fixture.Guid));
|
|
|
|
fixture.Runtime.RegisterLiveEntity(Fixture.Spawn(
|
|
PhysicsStateFlags.ReportCollisions,
|
|
instanceSequence: 2));
|
|
fixture.Runtime.MaterializeLiveEntity(
|
|
Fixture.Guid,
|
|
0x01010001u,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = Fixture.Guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = new Vector3(10f, 10f, 5f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
});
|
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
|
|
|
Assert.False(fixture.Controller.CompleteAuthoritativePlacement(
|
|
Fixture.Guid,
|
|
generation: 1,
|
|
deferShadowRestore: true));
|
|
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
|
Assert.True(fixture.Controller.BeginAuthoritativePlacement(Fixture.Guid, 2));
|
|
Assert.True(fixture.Controller.HasActivePlacement(Fixture.Guid));
|
|
|
|
fixture.Controller.Clear();
|
|
|
|
Assert.False(fixture.Controller.HasActivePlacement(Fixture.Guid));
|
|
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
|
}
|
|
|
|
private sealed class Fixture
|
|
{
|
|
public const uint Guid = 0x70000051u;
|
|
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,
|
|
Action<uint>? onPartArrayEnterWorld = null)
|
|
{
|
|
Spatial = new GpuWorldState();
|
|
Spatial.AddLandblock(new LoadedLandblock(
|
|
0x0101FFFFu,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
Runtime = new LiveEntityRuntime(
|
|
Spatial,
|
|
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
|
WorldSession.EntitySpawn spawn = Spawn(initialState);
|
|
Runtime.RegisterLiveEntity(spawn);
|
|
Entity = Runtime.MaterializeLiveEntity(
|
|
Guid,
|
|
0x01010001u,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = Guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = new Vector3(10f, 10f, 5f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
})!;
|
|
Shadows.Register(
|
|
Entity.Id,
|
|
0x01000001u,
|
|
Entity.Position,
|
|
Entity.Rotation,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0x01010000u,
|
|
state: (uint)initialState,
|
|
seedCellId: 0x01010001u);
|
|
|
|
Controller = new LiveEntityPresentationController(
|
|
Runtime,
|
|
Shadows,
|
|
(owner, type, intensity) =>
|
|
{
|
|
TypedPlays.Add((owner, type, intensity));
|
|
PresentationOrder.Add($"effect:{type:X2}");
|
|
return true;
|
|
},
|
|
(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(
|
|
PhysicsStateFlags state,
|
|
ushort instanceSequence = 1)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
|
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)state,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
Guid,
|
|
position,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
"fixture",
|
|
null,
|
|
null,
|
|
MotionTableId: 0x09000001u,
|
|
PhysicsState: (uint)state,
|
|
InstanceSequence: instanceSequence,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
}
|
|
}
|