acdream/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
Erik 75acae02d6 fix(world): complete recall before teleport hide
Match retail's update ordering so object animation, particles, and scripts advance before inbound teleport state is applied. Separate input-originated movement from post-network autonomous position output, and reconcile presentation without a second physics tick so recall cannot resume after arrival.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-16 06:37:29 +02:00

118 lines
4.3 KiB
C#

using AcDream.App.Rendering;
using AcDream.App.World;
using AcDream.Content.Vfx;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using Xunit.Sdk;
namespace AcDream.App.Tests.World;
public sealed class RecallTeleportAnimationTests
{
private const uint HumanSetup = 0x02000001u;
private const uint HumanMotionTable = 0x09000001u;
private const uint NonCombat = 0x8000003Du;
private const uint Ready = 0x41000003u;
private const uint LifestoneRecall = 0x10000153u;
[Fact]
public void LiveFrame_AdvancesObjectRuntimeBeforeInboundNetwork()
{
var calls = new List<string>();
var frame = new RetailLiveFrameCoordinator(
_ => calls.Add("objects"),
() => calls.Add("network"),
() => calls.Add("commands"),
() => calls.Add("spatial"));
frame.Tick(1f / 60f);
Assert.Equal(["objects", "network", "commands", "spatial"], calls);
}
[Fact]
public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch()
{
var deltas = new List<float>();
int networkDispatches = 0;
var frame = new RetailLiveFrameCoordinator(
deltas.Add,
() => networkDispatches++,
() => { },
() => { });
frame.Tick(float.NaN);
frame.Tick(float.PositiveInfinity);
frame.Tick(-1f);
Assert.Equal([0f, 0f, 0f], deltas);
Assert.Equal(3, networkDispatches);
Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NaN));
Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NegativeInfinity));
Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.PositiveInfinity));
Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.MaxValue));
}
[Fact]
public void InstalledRecall_RetiresBeforeTeleportHiddenStateIsDispatched()
{
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 loader = new RetailAnimationLoader(dats);
var sequencer = new AnimationSequencer(setup, table, loader);
sequencer.SetCycle(NonCombat, Ready);
sequencer.PlayAction(LifestoneRecall);
int readyKey = unchecked((int)((NonCombat << 16) | (Ready & 0xFFFFFFu)));
MotionData action = table.Links[readyKey].MotionData[unchecked((int)LifestoneRecall)];
double aceDuration = 0.0;
foreach (var anim in action.Anims)
{
Animation loaded = Assert.IsType<Animation>(loader.LoadAnimation((uint)anim.AnimId));
int high = anim.HighFrame == -1 ? loaded.PartFrames.Count : anim.HighFrame;
aceDuration += (high - anim.LowFrame) / Math.Abs(anim.Framerate);
}
const float updateStep = 1f / 60f;
double elapsed = 0.0;
bool hidden = false;
bool hiddenPacketReady = false;
var frame = new RetailLiveFrameCoordinator(
dt =>
{
if (!hidden)
sequencer.Advance(dt);
},
() =>
{
if (hiddenPacketReady)
hidden = true;
},
() => { },
() => { });
// The recall motion is dispatched after an object's UseTime phase.
// On the first later frame whose timestamp reaches ACE's scheduled
// teleport boundary, retail advances the PartArray and only then
// consumes the Hidden SetState from the inbound queue.
while (elapsed < aceDuration)
{
hiddenPacketReady = elapsed + updateStep >= aceDuration;
frame.Tick(updateStep);
elapsed += updateStep;
}
Assert.True(hidden);
Assert.True(
sequencer.CurrentNodeDiag.IsLooping,
"Recall must reach the Ready cyclic tail before Hidden freezes PartArray playback.");
}
}