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>
This commit is contained in:
parent
dded9e6b17
commit
75acae02d6
13 changed files with 1068 additions and 332 deletions
|
|
@ -0,0 +1,151 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
public sealed class RetailLocalPlayerFrameControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void F751BetweenPhases_CannotRelocateOrReplayCapturedOutboundMovement()
|
||||
{
|
||||
PlayerMovementController controller = CreateController();
|
||||
var calls = new List<string>();
|
||||
var preNetwork = new List<(PlayerState State, MovementResult Movement)>();
|
||||
var postNetwork = new List<PlayerState>();
|
||||
var local = new RetailLocalPlayerFrameController(
|
||||
canPresentPlayer: () => true,
|
||||
getController: () => controller,
|
||||
captureInput: () => new MovementInput(Forward: true),
|
||||
resolveLocalEntityId: () => 7u,
|
||||
handleTargeting: () => calls.Add("target"),
|
||||
isHidden: () => false,
|
||||
project: (_, _, _) => calls.Add("project"),
|
||||
sendPreNetwork: (owner, movement, _) =>
|
||||
{
|
||||
calls.Add("pre-outbound");
|
||||
preNetwork.Add((owner.State, movement));
|
||||
},
|
||||
sendPostNetwork: (owner, _) =>
|
||||
{
|
||||
calls.Add("post-position");
|
||||
postNetwork.Add(owner.State);
|
||||
});
|
||||
var live = new RetailLiveFrameCoordinator(
|
||||
dt =>
|
||||
{
|
||||
calls.Add("objects");
|
||||
local.AdvanceBeforeNetwork(dt);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
calls.Add("f751");
|
||||
controller.State = PlayerState.PortalSpace;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
calls.Add("commands");
|
||||
local.RunPostNetworkCommandPhase();
|
||||
},
|
||||
() => calls.Add("spatial"));
|
||||
|
||||
live.Tick(1f / 60f);
|
||||
Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"objects", "target", "project", "pre-outbound", "f751",
|
||||
"commands", "project", "post-position", "spatial",
|
||||
],
|
||||
calls);
|
||||
Assert.Single(preNetwork);
|
||||
Assert.Equal(PlayerState.InWorld, preNetwork[0].State);
|
||||
Assert.True(preNetwork[0].Movement.ShouldSendMovementEvent);
|
||||
Assert.Equal([PlayerState.PortalSpace], postNetwork);
|
||||
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
||||
Assert.True(presentation.AdvancedBeforeNetwork);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnerCreatedByInboundPass_IsProjectedWithoutPhysicsOrOutboundTick()
|
||||
{
|
||||
PlayerMovementController? controller = null;
|
||||
int projections = 0;
|
||||
int preNetwork = 0;
|
||||
int postNetwork = 0;
|
||||
var local = new RetailLocalPlayerFrameController(
|
||||
canPresentPlayer: () => controller is not null,
|
||||
getController: () => controller,
|
||||
captureInput: () => new MovementInput(Forward: true),
|
||||
resolveLocalEntityId: () => 9u,
|
||||
handleTargeting: () => { },
|
||||
isHidden: () => false,
|
||||
project: (_, _, _) => projections++,
|
||||
sendPreNetwork: (_, _, _) => preNetwork++,
|
||||
sendPostNetwork: (_, _) => postNetwork++);
|
||||
|
||||
local.AdvanceBeforeNetwork(1f / 60f);
|
||||
controller = CreateController();
|
||||
float timeBefore = controller.SimTimeSeconds;
|
||||
|
||||
Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
|
||||
|
||||
Assert.False(presentation.AdvancedBeforeNetwork);
|
||||
Assert.Equal(timeBefore, controller.SimTimeSeconds);
|
||||
Assert.Equal(1, projections);
|
||||
Assert.Equal(0, preNetwork);
|
||||
Assert.Equal(0, postNetwork);
|
||||
Assert.False(presentation.Movement.ShouldSendMovementEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrdinaryInboundRootMutation_IsReconciledWithoutSecondPhysicsTick()
|
||||
{
|
||||
PlayerMovementController controller = CreateController();
|
||||
Vector3 projectedRoot = default;
|
||||
var local = new RetailLocalPlayerFrameController(
|
||||
canPresentPlayer: () => true,
|
||||
getController: () => controller,
|
||||
captureInput: () => new MovementInput(),
|
||||
resolveLocalEntityId: () => 7u,
|
||||
handleTargeting: () => { },
|
||||
isHidden: () => false,
|
||||
project: (_, movement, _) => projectedRoot = movement.RenderPosition,
|
||||
sendPreNetwork: (_, _, _) => { },
|
||||
sendPostNetwork: (_, _) => { });
|
||||
var live = new RetailLiveFrameCoordinator(
|
||||
local.AdvanceBeforeNetwork,
|
||||
() => projectedRoot = new Vector3(999f, 999f, 999f),
|
||||
local.RunPostNetworkCommandPhase,
|
||||
() => { });
|
||||
|
||||
live.Tick(1f / 60f);
|
||||
float timeAfterOneTick = controller.SimTimeSeconds;
|
||||
|
||||
Assert.Equal(controller.RenderPosition, projectedRoot);
|
||||
Assert.Equal(1f / 60f, timeAfterOneTick);
|
||||
}
|
||||
|
||||
private static PlayerMovementController CreateController()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)50);
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < heightTable.Length; i++)
|
||||
heightTable[i] = i;
|
||||
|
||||
engine.AddLandblock(
|
||||
0xA9B4FFFFu,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
|
||||
var controller = new PlayerMovementController(engine);
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
118
tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
Normal file
118
tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue