diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 0080c8ba..ee8903d3 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -334,6 +334,17 @@ internal sealed class PlayerModeController : controller.Motion.DefaultSink = new MotionTableDispatchSink(sequencer); sequencer.Manager.HandleEnterWorld(); + // C3c constructs and grounds the Runtime-owned player before + // graphical PartArray presentation attaches. Ground entry can + // therefore enqueue CMotionInterp work while no animation sink + // exists; that work has no matching PartArray completion and + // would permanently sit ahead of later MoveTo/cast motions. + // First let the newly attached PartArray drain every matching + // animation above, then retire only the unmatched pre-attach + // interpreter suffix. In retail both owners exist together, + // so this suffix cannot arise; this is the split-lifetime + // reconciliation at their single attach boundary. + controller.Motion.HandleExitWorld(); } var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect }; diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs index db8a635b..5a7f463d 100644 --- a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs +++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs @@ -417,7 +417,17 @@ internal sealed class LiveEntityMotionRuntimeController if (update.MotionState.MovementType == 6 && path.TargetGuid is { } tgtGuid && _liveEntities is { } liveMoveEntities - && liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt)) + && liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt) + // Retail resolves the CPhysicsObj itself here. Even a static, + // animation-less object (door, chest, corpse, NPC prop) can + // therefore accept AddVoyeur and immediately publish its + // position. C3c moved the local player's GetObjectA seam into + // Runtime, where lookup is intentionally non-constructing; + // eagerly materialize the existing canonical minimal host at + // this App composition boundary before PerformMovement asks + // Runtime for it. Without this, MoveToObject arms but receives + // no target snapshot and times out at (0,0,0). + && ResolvePhysicsHost(tgtGuid) is not null) { ms.Type = AcDream.Core.Physics.MovementType.MoveToObject; ms.ObjectId = tgtGuid; diff --git a/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs b/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs index 056140cc..6c4659da 100644 --- a/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs +++ b/tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs @@ -37,6 +37,32 @@ public sealed class C3cF2AutoEntryWiringTests StringComparison.Ordinal); } + [Fact] + public void PlayerPresentationAttach_DrainsMatchedAnimationsBeforeStartupMotionSuffix() + { + string source = ReadSource("Input", "PlayerModeController.cs"); + + int attach = source.IndexOf( + "controller.Motion.DefaultSink =", + StringComparison.Ordinal); + int animationDrain = source.IndexOf( + "sequencer.Manager.HandleEnterWorld();", + attach, + StringComparison.Ordinal); + int unmatchedMotionDrain = source.IndexOf( + "controller.Motion.HandleExitWorld();", + animationDrain, + StringComparison.Ordinal); + + Assert.True(attach >= 0, "The player animation sink was not attached."); + Assert.True( + animationDrain > attach, + "Matching PartArray animations must drain after attaching the sink."); + Assert.True( + unmatchedMotionDrain > animationDrain, + "Only the unmatched pre-attach interpreter suffix may drain last."); + } + private static string ReadSource(params string[] relativePath) { DirectoryInfo? directory = new(AppContext.BaseDirectory); diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityMotionRuntimeControllerTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityMotionRuntimeControllerTests.cs new file mode 100644 index 00000000..4efcf728 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityMotionRuntimeControllerTests.cs @@ -0,0 +1,135 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.Selection; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Physics; + +public sealed class LiveEntityMotionRuntimeControllerTests +{ + [Fact] + public void MoveToObject_StaticTargetWithoutHost_MaterializesCanonicalMinimalHost() + { + const uint targetGuid = 0x70000091u; + const uint cellId = 0x01010001u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + var runtime = LiveEntityRuntimeFixture.Create( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + runtime.RegisterAndMaterializeProjection(Spawn(targetGuid, cellId)); + Assert.False(runtime.TryGetPhysicsHost(targetGuid, out _)); + + var origin = new LiveWorldOriginState(); + origin.Recenter(1, 1); + var controller = new LiveEntityMotionRuntimeController( + runtime, + new PhysicsDataCache(), + static () => null, + new SelectionState(), + origin); + var movement = new MovementManager(new MotionInterpreter()); + var update = new WorldSession.EntityMotionUpdate( + Guid: 0x50000001u, + MotionState: new CreateObject.ServerMotionState( + Stance: 0x3D, + ForwardCommand: null, + MovementType: 6, + MoveToParameters: 0x203u, + MoveToSpeed: 1f, + MoveToRunRate: 1f, + MoveToPath: new CreateObject.MoveToPathData( + TargetGuid: targetGuid, + OriginCellId: cellId, + OriginX: 10f, + OriginY: 10f, + OriginZ: 5f, + DistanceToObject: 0.6f, + MinDistance: 0f, + FailDistance: 15f, + WalkRunThreshold: 15f, + DesiredHeading: 0f, + Bitfield: 0x203u)), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 3, + IsAutonomous: false); + + Assert.True(controller.RouteServerMoveTo(movement, cellId, update)); + Assert.True(runtime.TryGetPhysicsHost(targetGuid, out var targetHost)); + Assert.IsType(targetHost); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cellId) + { + var position = new CreateObject.ServerPosition( + cellId, + 10f, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Static, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + 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: guid, + Position: position, + SetupTableId: 0x02000001u, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "static target", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: (uint)PhysicsStateFlags.Static, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +}