feat(vfx): port retail hidden and teleport presentation

Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
This commit is contained in:
Erik 2026-07-14 14:59:48 +02:00
parent a51ebc66e9
commit 1e98d81448
46 changed files with 4883 additions and 127 deletions

View file

@ -0,0 +1,82 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
namespace AcDream.App.Tests.Physics;
public sealed class PlayerMovementHiddenTests
{
[Fact]
public void TickHidden_PositionManagerMovesRootAndEffectPoseWithoutPhysicsInput()
{
const uint playerGuid = 0x50000001u;
const uint targetGuid = 0x50000002u;
const uint cellId = 0x01010001u;
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SetPosition(Vector3.Zero, cellId, Vector3.Zero);
var hosts = new Dictionary<uint, IPhysicsObjHost>();
EntityPhysicsHost? playerHost = null;
playerHost = new EntityPhysicsHost(
playerGuid,
() => controller.CellPosition,
() => controller.BodyVelocity,
() => 0.48f,
() => controller.BodyInContact,
() => 3f,
() => 0.1,
() => 0.1,
id => hosts.GetValueOrDefault(id),
info => playerHost!.PositionManager.HandleUpdateTarget(info),
() => { });
var targetPosition = new Position(
cellId,
new Vector3(5f, 0f, 0f),
Quaternion.Identity);
var targetHost = new EntityPhysicsHost(
targetGuid,
() => targetPosition,
() => Vector3.Zero,
() => 0.48f,
() => true,
() => null,
() => 0.1,
() => 0.1,
id => hosts.GetValueOrDefault(id),
_ => { },
() => { });
hosts.Add(playerGuid, playerHost);
hosts.Add(targetGuid, targetHost);
controller.PositionManager = playerHost.PositionManager;
playerHost.PositionManager.StickTo(targetGuid, radius: 0.48f, height: 1.835f);
var entity = new WorldEntity
{
Id = 7u,
ServerGuid = playerGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
ParentCellId = cellId,
MeshRefs = Array.Empty<MeshRef>(),
};
var poses = new EntityEffectPoseRegistry();
poses.Publish(entity, Array.Empty<Matrix4x4>());
MovementResult result = controller.TickHidden(0.1f);
entity.SetPosition(result.Position);
entity.ParentCellId = result.CellId;
Assert.True(poses.UpdateRoot(entity));
Assert.InRange(result.Position.X, 0.01f, 1.5f);
Assert.Equal(0f, result.Position.Z);
Assert.Equal(Vector3.Zero, controller.BodyVelocity);
Assert.True(poses.TryGetRootPose(entity.Id, out Matrix4x4 root));
Assert.Equal(result.Position, root.Translation);
}
}

View file

@ -52,6 +52,96 @@ public sealed class ProjectileControllerTests
Assert.Same(record.ProjectileRuntime!.Body, record.PhysicsBody);
}
[Fact]
public void Hidden_PausesProjectileWithoutClearingActiveOrReplayingClockBacklog()
{
var fixture = new Fixture();
LiveEntityRecord record = fixture.Spawn(instance: 1);
WorldEntity entity = record.WorldEntity!;
fixture.Engine.ShadowObjects.Register(
entity.Id,
entity.SourceGfxObjOrSetupId,
entity.Position,
entity.Rotation,
radius: 0.5f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x01010000u,
state: (uint)MissileState,
seedCellId: CellA);
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
PhysicsBody body = record.PhysicsBody!;
Assert.True(body.IsActive);
record.FinalPhysicsState = MissileState
| PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 1.1, 1, 1));
Vector3 hiddenPosition = entity.Position;
fixture.Controller.Tick(5.0, 1, 1, playerWorldPosition: null);
Assert.Equal(hiddenPosition, entity.Position);
Assert.True(body.InWorld);
Assert.True(body.IsActive);
Assert.Equal(5.0, body.LastUpdateTime, 8);
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 5.0, 1, 1));
fixture.Controller.Tick(5.1, 1, 1, playerWorldPosition: null);
Assert.True(entity.Position.X > hiddenPosition.X);
Assert.True(entity.Position.X < hiddenPosition.X + 2f);
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
}
[Fact]
public void HiddenSharedRemoteProjectile_UsesPositionManagerNarrowTickExactlyOnce()
{
var fixture = new Fixture();
LiveEntityRecord record = fixture.Spawn(instance: 1);
WorldEntity entity = record.WorldEntity!;
Assert.Null(record.AnimationRuntime);
var remote = new GameWindow.RemoteMotion();
RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation;
fixture.Live.SetRemoteMotionRuntime(Guid, remote);
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
record.FinalPhysicsState = MissileState
| PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 1.1, 1, 1));
remote.Interp.Enqueue(
entity.Position + Vector3.UnitX,
heading: 0f,
isMovingTo: false,
currentBodyPosition: remote.Body.Position);
var updater = new RemotePhysicsUpdater(
fixture.Engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
int publishedRoots = 0;
updater.TickHiddenEntities(
fixture.Live,
localPlayerServerGuid: 0x50000001u,
dt: 0.1f,
_ => publishedRoots++);
Vector3 afterNarrowTick = entity.Position;
fixture.Controller.Tick(1.2, 1, 1, playerWorldPosition: null);
Assert.True(afterNarrowTick.X > 10f);
Assert.Equal(afterNarrowTick, entity.Position);
Assert.Equal(1, publishedRoots);
Assert.True(fixture.Controller.HandlesMovement(Guid));
}
[Fact]
public void PendingDestinationAndLaterHydration_RetainIdentityAndProjectileOwner()
{

View file

@ -0,0 +1,166 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Tests.Physics;
public sealed class RemotePhysicsUpdaterTests
{
[Fact]
public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics()
{
var motion = new GameWindow.RemoteMotion();
motion.Body.Position = Vector3.Zero;
motion.Body.Orientation = Quaternion.Identity;
motion.Body.Velocity = new Vector3(4f, 0f, 5f);
motion.Body.Acceleration = new Vector3(0f, 0f, -9.8f);
motion.CellId = 0x01010001u;
motion.Interp.Enqueue(
new Vector3(1f, 0f, 0f),
heading: 0f,
isMovingTo: false,
currentBodyPosition: Vector3.Zero);
var entity = new WorldEntity
{
Id = 1u,
ServerGuid = 0x70000001u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
var updater = new RemotePhysicsUpdater(
new PhysicsEngine(),
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var poses = new EntityEffectPoseRegistry();
poses.Publish(entity, Array.Empty<Matrix4x4>());
updater.TickHidden(motion, entity, 0.1f);
Assert.True(poses.UpdateRoot(entity));
Assert.InRange(motion.Body.Position.X, 0.01f, 1f);
Assert.Equal(0f, motion.Body.Position.Z);
Assert.Equal(new Vector3(4f, 0f, 5f), motion.Body.Velocity);
Assert.Equal(motion.Body.Position, entity.Position);
Assert.Equal(motion.CellId, entity.ParentCellId);
Assert.True(poses.TryGetRootPose(entity.Id, out Matrix4x4 root));
Assert.Equal(entity.Position, root.Translation);
}
[Fact]
public void TickHidden_CrossCellCommitUpdatesCanonicalCellBeforeUnhide()
{
var engine = BuildBoundaryEngine();
var updater = new RemotePhysicsUpdater(
engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new GameWindow.RemoteMotion();
motion.Body.Position = new Vector3(191f, 10f, 50f);
motion.Body.Orientation = Quaternion.Identity;
motion.Body.TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable;
uint canonicalCell = 0xA9B40039u;
int canonicalWrites = 0;
motion.BindCanonicalCell(
() => canonicalCell,
value =>
{
canonicalCell = value;
canonicalWrites++;
});
motion.CellId = canonicalCell;
canonicalWrites = 0;
motion.Interp.Enqueue(
new Vector3(193f, 10f, 50f),
heading: 0f,
isMovingTo: false,
currentBodyPosition: motion.Body.Position);
var entity = new WorldEntity
{
Id = 1u,
ServerGuid = 0x70000002u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = motion.Body.Position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
updater.TickHidden(motion, entity, 2f);
Assert.Equal(0xAAB40001u, canonicalCell);
Assert.Equal(canonicalCell, entity.ParentCellId);
Assert.True(canonicalWrites > 0);
Assert.True(entity.Position.X > 192f);
}
[Fact]
public void TickHidden_LandingSynchronizesRemoteAirborneState()
{
var engine = BuildBoundaryEngine();
var updater = new RemotePhysicsUpdater(
engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new GameWindow.RemoteMotion
{
Airborne = true,
};
motion.Body.Position = new Vector3(10f, 10f, 50f);
motion.Body.Orientation = Quaternion.Identity;
motion.Body.TransientState = TransientStateFlags.Active;
motion.CellId = 0xA9B40001u;
motion.Interp.Enqueue(
new Vector3(11f, 10f, 50f),
heading: 0f,
isMovingTo: false,
currentBodyPosition: motion.Body.Position);
var entity = new WorldEntity
{
Id = 3u,
ServerGuid = 0x70000003u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = motion.Body.Position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
updater.TickHidden(motion, entity, 0.1f);
Assert.True(motion.Body.InContact);
Assert.True(motion.Body.OnWalkable);
Assert.False(motion.Airborne);
}
private static PhysicsEngine BuildBoundaryEngine()
{
static TerrainSurface FlatTerrain()
{
var heights = new byte[81];
var table = new float[256];
Array.Fill(table, 50f);
return new TerrainSurface(heights, table);
}
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
engine.AddLandblock(
0xA9B4FFFFu,
FlatTerrain(),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
engine.AddLandblock(
0xAAB4FFFFu,
FlatTerrain(),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
192f,
0f);
return engine;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
using AcDream.App.Physics;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Physics;
public sealed class RemoteTeleportHookTests
{
[Fact]
public void Execute_UsesRetailOrderAndExactCancelContext()
{
var calls = new List<string>();
WeenieError cancelContext = WeenieError.None;
RemoteTeleportHook.Execute(new RemoteTeleportHookActions(
CancelMoveTo: error =>
{
cancelContext = error;
calls.Add("cancel");
},
UnStick: () => calls.Add("unstick"),
StopInterpolating: () => calls.Add("stop-interpolating"),
UnConstrain: () => calls.Add("unconstrain"),
NotifyTeleported: () => calls.Add("notify-teleported"),
ReportCollisionEnd: () => calls.Add("collision-end")));
Assert.Equal(0x3Cu, (uint)cancelContext);
Assert.Equal(
[
"cancel",
"unstick",
"stop-interpolating",
"unconstrain",
"notify-teleported",
"collision-end",
],
calls);
}
}

View file

@ -0,0 +1,194 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.App.Tests.Physics;
public sealed class RemoteTeleportPlacementTests
{
[Fact]
public void Apply_HardSnapsFullFrameAndPhysicsClock()
{
var body = new PhysicsBody
{
Orientation = Quaternion.Identity,
LastUpdateTime = 2.0,
};
body.SnapToCell(
0x01010001u,
new Vector3(3f, 4f, 5f),
new Vector3(3f, 4f, 5f));
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body);
Quaternion orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(210f, 220f, 12f),
0x02020123u,
IsOnGround: false),
new Vector3(18f, 28f, 12f),
orientation,
17.5,
previousContact: body.InContact,
previousOnWalkable: body.OnWalkable);
Assert.Equal(new Vector3(210f, 220f, 12f), body.Position);
Assert.Equal(0x02020123u, body.CellPosition.ObjCellId);
Assert.Equal(new Vector3(18f, 28f, 12f), body.CellPosition.Frame.Origin);
Assert.Equal(orientation, body.Orientation);
Assert.Equal(17.5, body.LastUpdateTime);
Assert.True(body.InWorld);
}
[Fact]
public void Apply_InstallsPlacementDerivedContactAndSynchronizesAirborneState()
{
var velocity = new Vector3(1f, 2f, 3f);
var body = new PhysicsBody
{
Velocity = velocity,
TransientState = TransientStateFlags.Active,
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
var contactPlane = new Plane(Vector3.UnitZ, 0f);
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body)
{
Airborne = true,
};
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(200f, 200f, 50f),
0x02020001u,
IsOnGround: true,
InContact: true,
OnWalkable: true,
ContactPlane: contactPlane,
ContactPlaneCellId: 0x02020001u),
new Vector3(8f, 8f, 50f),
Quaternion.Identity,
9.0,
previousContact: false,
previousOnWalkable: false);
Assert.Equal(Vector3.Zero, body.Velocity);
Assert.True(body.InContact);
Assert.True(body.OnWalkable);
Assert.True(body.ContactPlaneValid);
Assert.Equal(contactPlane, body.ContactPlane);
Assert.False(remote.Airborne);
}
[Fact]
public void Apply_PendingHydrationUsesPreservedSourceFlagsBeforeCollisionResponse()
{
var originalVelocity = new Vector3(-3f, 0f, 0f);
var body = new PhysicsBody
{
Velocity = originalVelocity,
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
// ParkPending deliberately clears these flags while the destination
// landblock is absent. SetPositionInternal must still receive the
// grounded source edge captured before parking.
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body)
{
Airborne = true,
};
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(200f, 200f, 50f),
0x02020001u,
IsOnGround: true,
CollisionNormalValid: true,
CollisionNormal: Vector3.UnitX,
InContact: true,
OnWalkable: true,
ContactPlane: new Plane(Vector3.UnitZ, 0f),
ContactPlaneCellId: 0x02020001u),
new Vector3(8f, 8f, 50f),
Quaternion.Identity,
9.0,
previousContact: true,
previousOnWalkable: true);
// Staying on walkable ground suppresses reflection. If the parked
// false/false flags were used, the -X velocity would reflect to +X.
Assert.Equal(originalVelocity, body.Velocity);
Assert.True(body.OnWalkable);
Assert.False(remote.Airborne);
}
[Fact]
public void Apply_PendingGroundToSteepContact_RestoresSourceWalkabilityForFirstAcceleration()
{
var body = new PhysicsBody
{
Omega = new Vector3(0f, 0f, 3f),
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
// The unloaded-destination parking frame cleared both bits. Retail
// restores the captured source OnWalkable bit through the first
// calc_acceleration, which clears grounded angular drift, before
// set_on_walkable installs the steep destination's false value.
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body);
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(200f, 200f, 50f),
0x02020001u,
IsOnGround: false,
InContact: true,
OnWalkable: false,
ContactPlane: new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.2f)), 0f),
ContactPlaneCellId: 0x02020001u),
new Vector3(8f, 8f, 50f),
Quaternion.Identity,
9.0,
previousContact: true,
previousOnWalkable: true);
Assert.Equal(Vector3.Zero, body.Omega);
Assert.True(body.InContact);
Assert.False(body.OnWalkable);
Assert.True(remote.Airborne);
}
[Fact]
public void Apply_RejectsMalformedPlacementWithoutMutatingBody()
{
var original = new Vector3(7f, 8f, 9f);
var body = new PhysicsBody { Position = original };
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body);
Assert.Throws<ArgumentOutOfRangeException>(() =>
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(float.NaN, 0f, 0f),
0,
IsOnGround: false,
Ok: false),
Vector3.Zero,
Quaternion.Identity,
double.NaN,
previousContact: false,
previousOnWalkable: false));
Assert.Equal(original, body.Position);
Assert.Equal(0u, body.CellPosition.ObjCellId);
}
}