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:
parent
a51ebc66e9
commit
1e98d81448
46 changed files with 4883 additions and 127 deletions
|
|
@ -149,6 +149,17 @@ public sealed class PlayerMovementController
|
|||
/// </summary>
|
||||
public uint LocalEntityId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Applies the canonical server PhysicsState to the local body. Retail's
|
||||
/// <c>CPhysicsObj::SetState</c> replaces these persistent bits before its
|
||||
/// next acceleration/integration decision.
|
||||
/// </summary>
|
||||
public void ApplyPhysicsState(PhysicsStateFlags state)
|
||||
{
|
||||
_body.State = state;
|
||||
_body.calc_acceleration();
|
||||
}
|
||||
|
||||
public bool IsAirborne => !_body.OnWalkable;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -571,6 +582,86 @@ public sealed class PlayerMovementController
|
|||
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail Hidden slice of <c>CPhysicsObj::UpdatePositionInternal</c>
|
||||
/// (0x00512C30). Input, PartArray root motion, and physics integration are
|
||||
/// skipped, while PositionManager composition and the manager tail remain
|
||||
/// live. A composed offset still commits through CTransition so cell
|
||||
/// membership and contact state cannot become stale behind the hidden mesh.
|
||||
/// </summary>
|
||||
public MovementResult TickHidden(float dt)
|
||||
{
|
||||
_simTimeSeconds += dt;
|
||||
_physicsAccum = 0f;
|
||||
Vector3 previousPosition = _body.Position;
|
||||
bool previousContact = _body.InContact;
|
||||
bool previousOnWalkable = _body.OnWalkable;
|
||||
|
||||
if (PositionManager is { } manager)
|
||||
{
|
||||
var delta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
manager.AdjustOffset(delta, dt);
|
||||
if (delta.Origin != Vector3.Zero)
|
||||
_body.Position += Vector3.Transform(delta.Origin, _body.Orientation);
|
||||
if (!delta.Orientation.IsIdentity)
|
||||
{
|
||||
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
|
||||
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
|
||||
+ delta.GetHeading());
|
||||
_body.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
Yaw - MathF.PI / 2f);
|
||||
}
|
||||
}
|
||||
|
||||
if (_body.Position != previousPosition && CellId != 0 && _physics.LandblockCount > 0)
|
||||
{
|
||||
ResolveResult resolved = _physics.ResolveWithTransition(
|
||||
previousPosition,
|
||||
_body.Position,
|
||||
CellId,
|
||||
sphereRadius: 0.48f,
|
||||
sphereHeight: 1.835f,
|
||||
stepUpHeight: StepUpHeight,
|
||||
stepDownHeight: StepDownHeight,
|
||||
isOnGround: previousOnWalkable,
|
||||
body: _body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: LocalEntityId);
|
||||
_body.Position = resolved.Position;
|
||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
_body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
resolved.CollisionNormalValid,
|
||||
resolved.CollisionNormal,
|
||||
previousContact,
|
||||
previousOnWalkable,
|
||||
Movement.HitGround,
|
||||
_motion.LeaveGround);
|
||||
UpdateCellId(resolved.CellId, "hidden-position-manager");
|
||||
}
|
||||
|
||||
Movement.UseTime();
|
||||
PositionManager?.UseTime();
|
||||
_prevPhysicsPos = _body.Position;
|
||||
_currPhysicsPos = _body.Position;
|
||||
_wasAirborneLastFrame = !_body.OnWalkable;
|
||||
|
||||
return new MovementResult(
|
||||
Position: _body.Position,
|
||||
RenderPosition: _body.Position,
|
||||
CellId: CellId,
|
||||
IsOnGround: _body.OnWalkable,
|
||||
MotionStateChanged: false,
|
||||
ForwardCommand: null,
|
||||
SidestepCommand: null,
|
||||
TurnCommand: null,
|
||||
ForwardSpeed: null,
|
||||
SidestepSpeed: null,
|
||||
TurnSpeed: null);
|
||||
}
|
||||
|
||||
public MovementResult Update(float dt, MovementInput input)
|
||||
{
|
||||
_simTimeSeconds += dt;
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ internal sealed class ProjectileController
|
|||
entity.MeshRefs.Count > 0);
|
||||
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
|
||||
|
||||
if (HasVisibleCell(record))
|
||||
if (HasVisibleCell(record) && !IsHidden(record))
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
|
|
@ -262,6 +262,15 @@ internal sealed class ProjectileController
|
|||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
else if (HasVisibleCell(record))
|
||||
{
|
||||
// Hidden is a retained in-world CPhysicsObj, not leave_world.
|
||||
// Keep Active/identity but remove collision rows and consume the
|
||||
// hidden clock so UnHide cannot replay a time backlog.
|
||||
body.InWorld = true;
|
||||
body.LastUpdateTime = currentTime;
|
||||
_shadows.Suspend(entity.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
|
|
@ -300,7 +309,8 @@ internal sealed class ProjectileController
|
|||
|
||||
SetVelocity(runtime.Body, velocity, currentTime);
|
||||
runtime.Body.Omega = angularVelocity;
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid))
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid)
|
||||
&& !IsHidden(record))
|
||||
Deactivate(runtime.Body);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -425,7 +435,7 @@ internal sealed class ProjectileController
|
|||
entity.Rotation = orientation;
|
||||
entity.ParentCellId = canonicalCellId;
|
||||
_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId);
|
||||
if (HasVisibleCell(record))
|
||||
if (HasVisibleCell(record) && !IsHidden(record))
|
||||
{
|
||||
if (!wasInWorld)
|
||||
Activate(body, currentTime);
|
||||
|
|
@ -439,6 +449,12 @@ internal sealed class ProjectileController
|
|||
liveCenterX,
|
||||
liveCenterY);
|
||||
}
|
||||
else if (HasVisibleCell(record))
|
||||
{
|
||||
body.InWorld = true;
|
||||
body.LastUpdateTime = currentTime;
|
||||
_shadows.Suspend(entity.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
|
|
@ -496,6 +512,27 @@ internal sealed class ProjectileController
|
|||
|| record.WorldEntity is not { } entity)
|
||||
continue;
|
||||
|
||||
runtime.Body.State = record.FinalPhysicsState;
|
||||
if (IsHidden(record))
|
||||
{
|
||||
if (!HasVisibleCell(record))
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// UpdatePositionInternal skips root physics/PartArray
|
||||
// while Hidden, but update_object still advances its
|
||||
// clock and does not clear Active.
|
||||
runtime.Body.InWorld = true;
|
||||
runtime.Body.LastUpdateTime = currentTime;
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0)
|
||||
Deactivate(runtime.Body);
|
||||
_shadows.Suspend(entity.Id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isMissile =
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0;
|
||||
if (!isMissile)
|
||||
|
|
@ -530,7 +567,6 @@ internal sealed class ProjectileController
|
|||
continue;
|
||||
}
|
||||
|
||||
runtime.Body.State = record.FinalPhysicsState;
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid))
|
||||
{
|
||||
if (!HasVisibleCell(record))
|
||||
|
|
@ -637,6 +673,9 @@ internal sealed class ProjectileController
|
|||
&& record.IsSpatiallyVisible
|
||||
&& record.FullCellId != 0;
|
||||
|
||||
private static bool IsHidden(LiveEntityRecord record) =>
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
|
||||
|
||||
private void SuspendOutsideWorld(Runtime runtime, uint localEntityId)
|
||||
{
|
||||
runtime.Body.InWorld = false;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ namespace AcDream.App.Physics;
|
|||
/// frame, from inside the same guard the body used to live under
|
||||
/// (<c>ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid
|
||||
/// && rm.LastServerPosTime > 0</c>).
|
||||
/// Hidden remotes use a separate live-entity pass because retail keeps their
|
||||
/// PositionManager alive even when the object has no render-animation owner.
|
||||
///
|
||||
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
|
||||
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
|
||||
|
|
@ -55,6 +57,35 @@ internal sealed class RemotePhysicsUpdater
|
|||
// Duplicated one-liner (GameWindow keeps its own copy — many callers there).
|
||||
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||
|
||||
/// <summary>
|
||||
/// Advances the retained PositionManager path for every hidden remote,
|
||||
/// including objects without an <c>AnimatedEntity</c> (missiles commonly
|
||||
/// have no PartArray). Retail gates this path on the live CPhysicsObj, not
|
||||
/// on whether a render-animation owner exists.
|
||||
/// </summary>
|
||||
public void TickHiddenEntities(
|
||||
AcDream.App.World.LiveEntityRuntime liveEntities,
|
||||
uint localPlayerServerGuid,
|
||||
float dt,
|
||||
System.Action<AcDream.Core.World.WorldEntity> publishRootPose)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(liveEntities);
|
||||
ArgumentNullException.ThrowIfNull(publishRootPose);
|
||||
|
||||
foreach (AcDream.App.World.LiveEntityRecord record in liveEntities.Records.ToArray())
|
||||
{
|
||||
if (record.ServerGuid == localPlayerServerGuid
|
||||
|| (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0
|
||||
|| !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)
|
||||
|| record.RemoteMotionRuntime is not RemoteMotion remote
|
||||
|| record.WorldEntity is not { } entity)
|
||||
continue;
|
||||
|
||||
TickHidden(remote, entity, dt);
|
||||
publishRootPose(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
|
||||
/// shape), verbatim from the former <c>GameWindow.TickAnimations</c> guard
|
||||
|
|
@ -556,6 +587,99 @@ internal sealed class RemotePhysicsUpdater
|
|||
rm.Host?.PositionManager.UseTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail hidden-object slice of <c>CPhysicsObj::UpdatePositionInternal</c>
|
||||
/// (<c>0x00512C30</c>) plus the manager tail of
|
||||
/// <c>UpdateObjectInternal</c> (<c>0x005156B0</c>). Hidden skips
|
||||
/// <c>CPartArray::Update</c> and <c>UpdatePhysicsInternal</c>, but the
|
||||
/// PositionManager offset is still composed and the target, movement, and
|
||||
/// position managers still consume time. Physics-script and particle owners
|
||||
/// tick later in the shared frame pipeline.
|
||||
/// </summary>
|
||||
public void TickHidden(
|
||||
RemoteMotion rm,
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
float dt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rm);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
System.Numerics.Vector3 preComposePosition = rm.Body.Position;
|
||||
|
||||
// The part-array contribution is the identity frame while Hidden.
|
||||
// Interpolation is the first PositionManager stage in retail; acdream
|
||||
// retains it in RemoteMotionCombiner, ahead of Sticky/Constraint.
|
||||
System.Numerics.Vector3 interpolationOffset = rm.Position.ComputeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
System.Numerics.Vector3.Zero,
|
||||
rm.Body.Orientation,
|
||||
rm.Interp,
|
||||
rm.Motion.GetMaxSpeed());
|
||||
var positionDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation,
|
||||
interpolationOffset),
|
||||
};
|
||||
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, positionDelta);
|
||||
|
||||
System.Numerics.Vector3 composedPosition = rm.Body.Position;
|
||||
if (rm.CellId != 0
|
||||
&& composedPosition != preComposePosition
|
||||
&& _physicsEngine.LandblockCount > 0)
|
||||
{
|
||||
var (radius, height) = _getSetupCylinder(entity.ServerGuid, entity);
|
||||
if (radius < 0.05f)
|
||||
{
|
||||
radius = 0.48f;
|
||||
height = 1.835f;
|
||||
}
|
||||
|
||||
bool previousContact = rm.Body.InContact;
|
||||
bool previousOnWalkable = rm.Body.OnWalkable;
|
||||
var resolved = _physicsEngine.ResolveWithTransition(
|
||||
preComposePosition,
|
||||
composedPosition,
|
||||
rm.CellId,
|
||||
radius,
|
||||
height,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: previousOnWalkable,
|
||||
body: rm.Body,
|
||||
moverFlags: IsPlayerGuid(entity.ServerGuid)
|
||||
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
|
||||
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: entity.Id);
|
||||
rm.Body.Position = resolved.Position;
|
||||
if (resolved.CellId != 0)
|
||||
rm.CellId = resolved.CellId;
|
||||
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
rm.Body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
resolved.CollisionNormalValid,
|
||||
resolved.CollisionNormal,
|
||||
previousContact,
|
||||
previousOnWalkable,
|
||||
rm.Movement.HitGround,
|
||||
rm.Motion.LeaveGround);
|
||||
rm.Airborne = !rm.Body.OnWalkable;
|
||||
}
|
||||
|
||||
entity.SetPosition(rm.Body.Position);
|
||||
if (rm.CellId != 0)
|
||||
entity.ParentCellId = rm.CellId;
|
||||
entity.Rotation = rm.Body.Orientation;
|
||||
|
||||
rm.Host?.HandleTargetting();
|
||||
TickRemoteMoveTo(rm);
|
||||
rm.Host?.PositionManager.UseTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
|
||||
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
|
||||
|
|
@ -605,16 +729,36 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
|
||||
/// UP-branch tail.
|
||||
/// </summary>
|
||||
public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY)
|
||||
public void SyncRemoteShadowToBody(
|
||||
uint entityId,
|
||||
AcDream.App.World.ILiveEntityRemotePlacementRuntime rm,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
uint? authoritativeCellId = null)
|
||||
{
|
||||
SyncRemoteShadowToBody(
|
||||
entityId,
|
||||
rm.Body,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
authoritativeCellId ?? rm.CellId);
|
||||
rm.LastShadowSyncPosition = rm.Body.Position;
|
||||
}
|
||||
|
||||
public void SyncRemoteShadowToBody(
|
||||
uint entityId,
|
||||
AcDream.Core.Physics.PhysicsBody body,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
uint authoritativeCellId)
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_physicsEngine.ShadowObjects,
|
||||
entityId,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
rm.CellId,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
authoritativeCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
rm.LastShadowSyncPos = rm.Body.Position;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
444
src/AcDream.App/Physics/RemoteTeleportController.cs
Normal file
444
src/AcDream.App/Physics/RemoteTeleportController.cs
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the SetPosition half of retail remote
|
||||
/// <c>CPhysicsObj::MoveOrTeleport</c> (0x00516330). It resolves the
|
||||
/// destination through the placement transition, commits that transition to
|
||||
/// the existing body, and synchronizes the remote movement/contact state.
|
||||
/// Logical identity, target-hook actions, rebucketing callbacks, and render
|
||||
/// presentation remain with their existing owners.
|
||||
/// </summary>
|
||||
internal sealed class RemoteTeleportController : IDisposable
|
||||
{
|
||||
internal delegate ResolveResult PlacementResolver(
|
||||
Vector3 position,
|
||||
uint cellId,
|
||||
float radius,
|
||||
float height,
|
||||
ObjectInfoState moverFlags,
|
||||
uint movingEntityId);
|
||||
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _getSetupCylinder;
|
||||
private readonly Func<Vector3, uint, Vector3> _cellLocalForSeed;
|
||||
private readonly Action<WorldEntity, PhysicsBody, uint> _syncResolvedShadow;
|
||||
private readonly Action<uint, ushort, bool> _completeAuthoritativePlacement;
|
||||
private readonly Action<uint, ushort> _beginAuthoritativePlacement;
|
||||
private readonly PlacementResolver _resolvePlacement;
|
||||
private readonly Dictionary<uint, PendingPlacement> _pending = new();
|
||||
|
||||
internal RemoteTeleportController(
|
||||
PhysicsEngine physics,
|
||||
LiveEntityRuntime liveEntities,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder,
|
||||
Func<Vector3, uint, Vector3> cellLocalForSeed,
|
||||
Action<WorldEntity, PhysicsBody, uint> syncResolvedShadow,
|
||||
Action<uint, ushort, bool> completeAuthoritativePlacement,
|
||||
Action<uint, ushort> beginAuthoritativePlacement,
|
||||
PlacementResolver? resolvePlacement = null)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_getSetupCylinder = getSetupCylinder
|
||||
?? throw new ArgumentNullException(nameof(getSetupCylinder));
|
||||
_cellLocalForSeed = cellLocalForSeed
|
||||
?? throw new ArgumentNullException(nameof(cellLocalForSeed));
|
||||
_syncResolvedShadow = syncResolvedShadow
|
||||
?? throw new ArgumentNullException(nameof(syncResolvedShadow));
|
||||
_completeAuthoritativePlacement = completeAuthoritativePlacement
|
||||
?? throw new ArgumentNullException(nameof(completeAuthoritativePlacement));
|
||||
_beginAuthoritativePlacement = beginAuthoritativePlacement
|
||||
?? throw new ArgumentNullException(nameof(beginAuthoritativePlacement));
|
||||
_resolvePlacement = resolvePlacement ?? ResolvePlacement;
|
||||
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||
}
|
||||
|
||||
internal readonly record struct Result(
|
||||
bool Applied,
|
||||
bool ContactResolved,
|
||||
Vector3 Position,
|
||||
uint CellId,
|
||||
Quaternion Orientation);
|
||||
|
||||
private readonly record struct PendingPlacement(
|
||||
ILiveEntityRemotePlacementRuntime Remote,
|
||||
PhysicsBody Body,
|
||||
WorldEntity Entity,
|
||||
Vector3 RequestedWorldPosition,
|
||||
uint RequestedCellId,
|
||||
Quaternion RequestedOrientation,
|
||||
double GameTime,
|
||||
ushort Generation,
|
||||
ushort PositionSequence,
|
||||
bool WasInContact,
|
||||
bool WasOnWalkable,
|
||||
RollbackPlacement Rollback);
|
||||
|
||||
private readonly record struct RollbackPlacement(
|
||||
Vector3 Position,
|
||||
uint CellId,
|
||||
Vector3 CellLocalPosition,
|
||||
Quaternion Orientation,
|
||||
TransientStateFlags TransientState,
|
||||
Plane ContactPlane,
|
||||
bool ContactPlaneValid,
|
||||
uint ContactPlaneCellId,
|
||||
bool ContactPlaneIsWater,
|
||||
double LastUpdateTime,
|
||||
bool Airborne,
|
||||
Vector3 LastServerPosition,
|
||||
double LastServerPositionTime,
|
||||
Vector3 LastShadowSyncPosition);
|
||||
|
||||
internal Result TryApply(
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
WorldEntity entity,
|
||||
Vector3 requestedWorldPosition,
|
||||
uint requestedCellId,
|
||||
Vector3 requestedCellLocalPosition,
|
||||
Quaternion requestedOrientation,
|
||||
double gameTime,
|
||||
bool destinationProjectionVisible,
|
||||
ushort generation,
|
||||
ushort positionSequence)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord)
|
||||
|| liveRecord.Generation != generation
|
||||
|| liveRecord.PhysicsBody is null
|
||||
|| !ReferenceEquals(liveRecord.PhysicsBody, remote.Body))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote placement for 0x{entity.ServerGuid:X8} must use its incarnation's canonical physics body.");
|
||||
}
|
||||
|
||||
bool wasInContact = liveRecord.PhysicsBody.InContact;
|
||||
bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable;
|
||||
RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody);
|
||||
if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior)
|
||||
&& prior.Generation == generation)
|
||||
{
|
||||
wasInContact = prior.WasInContact;
|
||||
wasOnWalkable = prior.WasOnWalkable;
|
||||
rollback = prior.Rollback;
|
||||
}
|
||||
|
||||
PendingPlacement request = new(
|
||||
remote,
|
||||
liveRecord.PhysicsBody,
|
||||
entity,
|
||||
requestedWorldPosition,
|
||||
requestedCellId,
|
||||
requestedOrientation,
|
||||
gameTime,
|
||||
generation,
|
||||
positionSequence,
|
||||
wasInContact,
|
||||
wasOnWalkable,
|
||||
rollback);
|
||||
if (!destinationProjectionVisible)
|
||||
{
|
||||
ParkPending(request, requestedCellLocalPosition);
|
||||
return new Result(
|
||||
true,
|
||||
false,
|
||||
requestedWorldPosition,
|
||||
requestedCellId,
|
||||
requestedOrientation);
|
||||
}
|
||||
ResolveResult placement = Resolve(request);
|
||||
if (!placement.Ok)
|
||||
{
|
||||
_pending.Remove(entity.ServerGuid);
|
||||
RollBackDeferredPlacement(request);
|
||||
return new Result(
|
||||
false,
|
||||
false,
|
||||
request.Body.Position,
|
||||
remote.CellId,
|
||||
request.Body.Orientation);
|
||||
}
|
||||
|
||||
_pending.Remove(entity.ServerGuid);
|
||||
return CommitResolved(request, placement);
|
||||
}
|
||||
|
||||
internal void Forget(uint serverGuid)
|
||||
{
|
||||
_pending.Remove(serverGuid);
|
||||
}
|
||||
|
||||
internal void Clear()
|
||||
{
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
internal bool HasPending(uint serverGuid) => _pending.ContainsKey(serverGuid);
|
||||
|
||||
/// <summary>
|
||||
/// Transfers any older deferred shadow restoration before the accepted
|
||||
/// destination is rebucketed and can publish a visibility edge.
|
||||
/// </summary>
|
||||
internal void BeginPlacement(uint serverGuid, ushort generation) =>
|
||||
_beginAuthoritativePlacement(serverGuid, generation);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
|
||||
Clear();
|
||||
}
|
||||
|
||||
private ResolveResult Resolve(PendingPlacement request)
|
||||
{
|
||||
var (radius, height) = _getSetupCylinder(
|
||||
request.Entity.ServerGuid,
|
||||
request.Entity);
|
||||
if (radius < 0.05f)
|
||||
{
|
||||
radius = 0.48f;
|
||||
height = 1.835f;
|
||||
}
|
||||
|
||||
return _resolvePlacement(
|
||||
request.RequestedWorldPosition,
|
||||
request.RequestedCellId,
|
||||
radius,
|
||||
height,
|
||||
IsPlayerGuid(request.Entity.ServerGuid)
|
||||
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
|
||||
: ObjectInfoState.EdgeSlide,
|
||||
request.Entity.Id);
|
||||
}
|
||||
|
||||
private ResolveResult ResolvePlacement(
|
||||
Vector3 position,
|
||||
uint cellId,
|
||||
float radius,
|
||||
float height,
|
||||
ObjectInfoState moverFlags,
|
||||
uint movingEntityId) =>
|
||||
_physics.ResolvePlacement(
|
||||
position,
|
||||
cellId,
|
||||
radius,
|
||||
height,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
moverFlags: moverFlags,
|
||||
movingEntityId: movingEntityId);
|
||||
|
||||
private Result CommitResolved(PendingPlacement request, ResolveResult placement)
|
||||
{
|
||||
RemoteTeleportPlacement.Apply(
|
||||
request.Remote,
|
||||
request.Body,
|
||||
placement,
|
||||
_cellLocalForSeed(placement.Position, placement.CellId),
|
||||
request.RequestedOrientation,
|
||||
request.GameTime,
|
||||
request.WasInContact,
|
||||
request.WasOnWalkable);
|
||||
if (request.Remote.CellId != placement.CellId)
|
||||
request.Remote.CellId = placement.CellId;
|
||||
request.Remote.LastServerPosition = placement.Position;
|
||||
request.Remote.LastServerPositionTime = request.GameTime;
|
||||
request.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
request.Entity.SetPosition(request.Body.Position);
|
||||
request.Entity.ParentCellId = placement.CellId;
|
||||
request.Entity.Rotation = request.Body.Orientation;
|
||||
if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record))
|
||||
{
|
||||
bool deferShadowRestore =
|
||||
record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
|
||||
|| !record.IsSpatiallyVisible;
|
||||
_completeAuthoritativePlacement(
|
||||
request.Entity.ServerGuid,
|
||||
request.Generation,
|
||||
deferShadowRestore);
|
||||
if (!deferShadowRestore)
|
||||
{
|
||||
request.Remote.LastShadowSyncPosition = request.Body.Position;
|
||||
_syncResolvedShadow(request.Entity, request.Body, placement.CellId);
|
||||
}
|
||||
}
|
||||
return new Result(
|
||||
true,
|
||||
true,
|
||||
request.Body.Position,
|
||||
placement.CellId,
|
||||
request.Body.Orientation);
|
||||
}
|
||||
|
||||
private void ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition)
|
||||
{
|
||||
request.Body.Orientation = request.RequestedOrientation;
|
||||
request.Body.SnapToCell(
|
||||
request.RequestedCellId,
|
||||
request.RequestedWorldPosition,
|
||||
requestedCellLocalPosition);
|
||||
request.Body.LastUpdateTime = request.GameTime;
|
||||
request.Body.ContactPlaneValid = false;
|
||||
request.Body.ContactPlaneCellId = 0u;
|
||||
request.Body.ContactPlaneIsWater = false;
|
||||
PhysicsObjUpdate.ApplySetPositionContact(
|
||||
request.Body,
|
||||
inContact: false,
|
||||
onWalkable: false);
|
||||
request.Remote.Airborne = true;
|
||||
if (request.Remote.CellId != request.RequestedCellId)
|
||||
request.Remote.CellId = request.RequestedCellId;
|
||||
request.Remote.LastServerPosition = request.RequestedWorldPosition;
|
||||
request.Remote.LastServerPositionTime = request.GameTime;
|
||||
request.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
request.Entity.SetPosition(request.RequestedWorldPosition);
|
||||
request.Entity.ParentCellId = request.RequestedCellId;
|
||||
request.Entity.Rotation = request.RequestedOrientation;
|
||||
_pending[request.Entity.ServerGuid] = request;
|
||||
}
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
if (!visible)
|
||||
return;
|
||||
|
||||
if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending))
|
||||
{
|
||||
if (record.WorldEntity is null
|
||||
|| !ReferenceEquals(record.WorldEntity, pending.Entity)
|
||||
|| record.Generation != pending.Generation)
|
||||
{
|
||||
_pending.Remove(record.ServerGuid);
|
||||
return;
|
||||
}
|
||||
if (record.RemoteMotionRuntime is not ILiveEntityRemotePlacementRuntime currentRemote
|
||||
|| record.PhysicsBody is null
|
||||
|| !ReferenceEquals(record.PhysicsBody, pending.Body)
|
||||
|| !ReferenceEquals(currentRemote.Body, record.PhysicsBody))
|
||||
{
|
||||
_pending.Remove(record.ServerGuid);
|
||||
if (record.RemoteMotionRuntime is not null
|
||||
&& (record.PhysicsBody is null
|
||||
|| !ReferenceEquals(record.RemoteMotionRuntime.Body, record.PhysicsBody)))
|
||||
{
|
||||
_liveEntities.ClearRemoteMotionRuntime(record.ServerGuid);
|
||||
}
|
||||
RollBackDeferredPlacement(pending);
|
||||
return;
|
||||
}
|
||||
if (!ReferenceEquals(currentRemote, pending.Remote))
|
||||
{
|
||||
pending = pending with { Remote = currentRemote };
|
||||
_pending[record.ServerGuid] = pending;
|
||||
}
|
||||
if (record.Snapshot.PositionSequence != pending.PositionSequence)
|
||||
{
|
||||
// A newer accepted UpdatePosition rebucketed the projection
|
||||
// before its TryApply call could replace this request. Keep
|
||||
// the original rollback alive for that synchronous call.
|
||||
return;
|
||||
}
|
||||
|
||||
_pending.Remove(record.ServerGuid);
|
||||
ResolveResult placement = Resolve(pending);
|
||||
if (!placement.Ok)
|
||||
RollBackDeferredPlacement(pending);
|
||||
else
|
||||
CommitResolved(pending, placement);
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static RollbackPlacement CaptureRollback(
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
PhysicsBody body)
|
||||
{
|
||||
return new RollbackPlacement(
|
||||
body.Position,
|
||||
body.CellPosition.ObjCellId,
|
||||
body.CellPosition.Frame.Origin,
|
||||
body.Orientation,
|
||||
body.TransientState,
|
||||
body.ContactPlane,
|
||||
body.ContactPlaneValid,
|
||||
body.ContactPlaneCellId,
|
||||
body.ContactPlaneIsWater,
|
||||
body.LastUpdateTime,
|
||||
remote.Airborne,
|
||||
remote.LastServerPosition,
|
||||
remote.LastServerPositionTime,
|
||||
remote.LastShadowSyncPosition);
|
||||
}
|
||||
|
||||
private void RollBackDeferredPlacement(PendingPlacement pending)
|
||||
{
|
||||
RollbackPlacement rollback = pending.Rollback;
|
||||
PhysicsBody body = pending.Body;
|
||||
body.Orientation = rollback.Orientation;
|
||||
body.SnapToCell(
|
||||
rollback.CellId,
|
||||
rollback.Position,
|
||||
rollback.CellLocalPosition);
|
||||
body.TransientState = rollback.TransientState;
|
||||
body.ContactPlane = rollback.ContactPlane;
|
||||
body.ContactPlaneValid = rollback.ContactPlaneValid;
|
||||
body.ContactPlaneCellId = rollback.ContactPlaneCellId;
|
||||
body.ContactPlaneIsWater = rollback.ContactPlaneIsWater;
|
||||
body.LastUpdateTime = rollback.LastUpdateTime;
|
||||
body.calc_acceleration();
|
||||
pending.Remote.Airborne = rollback.Airborne;
|
||||
pending.Remote.CellId = rollback.CellId;
|
||||
pending.Remote.LastServerPosition = rollback.LastServerPosition;
|
||||
pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime;
|
||||
pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition;
|
||||
pending.Entity.SetPosition(rollback.Position);
|
||||
pending.Entity.ParentCellId = rollback.CellId;
|
||||
pending.Entity.Rotation = rollback.Orientation;
|
||||
|
||||
if (rollback.CellId == 0)
|
||||
_liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid);
|
||||
else
|
||||
_liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId);
|
||||
|
||||
if (!_liveEntities.TryGetRecord(
|
||||
pending.Entity.ServerGuid,
|
||||
out LiveEntityRecord restored)
|
||||
|| restored.Generation != pending.Generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (rollback.CellId == 0)
|
||||
{
|
||||
_completeAuthoritativePlacement(
|
||||
pending.Entity.ServerGuid,
|
||||
pending.Generation,
|
||||
false);
|
||||
return;
|
||||
}
|
||||
|
||||
bool deferShadowRestore =
|
||||
restored.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
|
||||
|| !restored.IsSpatiallyVisible;
|
||||
_completeAuthoritativePlacement(
|
||||
pending.Entity.ServerGuid,
|
||||
pending.Generation,
|
||||
deferShadowRestore);
|
||||
if (deferShadowRestore)
|
||||
return;
|
||||
|
||||
pending.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
_syncResolvedShadow(pending.Entity, pending.Body, rollback.CellId);
|
||||
}
|
||||
|
||||
private static bool IsPlayerGuid(uint guid) =>
|
||||
(guid & 0xFF000000u) == 0x50000000u;
|
||||
}
|
||||
39
src/AcDream.App/Physics/RemoteTeleportHook.cs
Normal file
39
src/AcDream.App/Physics/RemoteTeleportHook.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Ordered App seam for retail <c>CPhysicsObj::teleport_hook</c>
|
||||
/// (<c>0x00514ED0</c>). The action bundle keeps the port independently
|
||||
/// testable while the concrete movement/interpolation/target owners remain
|
||||
/// in the composition root.
|
||||
/// </summary>
|
||||
public static class RemoteTeleportHook
|
||||
{
|
||||
private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu;
|
||||
|
||||
public static void Execute(RemoteTeleportHookActions actions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actions.CancelMoveTo);
|
||||
ArgumentNullException.ThrowIfNull(actions.UnStick);
|
||||
ArgumentNullException.ThrowIfNull(actions.StopInterpolating);
|
||||
ArgumentNullException.ThrowIfNull(actions.UnConstrain);
|
||||
ArgumentNullException.ThrowIfNull(actions.NotifyTeleported);
|
||||
ArgumentNullException.ThrowIfNull(actions.ReportCollisionEnd);
|
||||
|
||||
actions.CancelMoveTo(TeleportCancelContext);
|
||||
actions.UnStick();
|
||||
actions.StopInterpolating();
|
||||
actions.UnConstrain();
|
||||
actions.NotifyTeleported();
|
||||
actions.ReportCollisionEnd();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RemoteTeleportHookActions(
|
||||
Action<WeenieError> CancelMoveTo,
|
||||
Action UnStick,
|
||||
Action StopInterpolating,
|
||||
Action UnConstrain,
|
||||
Action NotifyTeleported,
|
||||
Action ReportCollisionEnd);
|
||||
77
src/AcDream.App/Physics/RemoteTeleportPlacement.cs
Normal file
77
src/AcDream.App/Physics/RemoteTeleportPlacement.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Commits the placement half of retail <c>CPhysicsObj::MoveOrTeleport</c>
|
||||
/// Branch A (<c>0x00516330</c>). The caller runs
|
||||
/// <see cref="RemoteTeleportHook"/> first, then this exact frame/cell snap,
|
||||
/// before considering contact-driven interpolation.
|
||||
/// </summary>
|
||||
internal static class RemoteTeleportPlacement
|
||||
{
|
||||
internal static void Apply(
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
PhysicsBody body,
|
||||
ResolveResult placement,
|
||||
Vector3 cellLocalPosition,
|
||||
Quaternion orientation,
|
||||
double gameTime,
|
||||
bool previousContact,
|
||||
bool previousOnWalkable)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
if (!placement.Ok
|
||||
|| !IsFinite(placement.Position)
|
||||
|| !IsFinite(cellLocalPosition)
|
||||
|| !PositionFrameValidation.IsValid(
|
||||
placement.CellId,
|
||||
cellLocalPosition,
|
||||
orientation)
|
||||
|| !double.IsFinite(gameTime))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(placement),
|
||||
"A remote teleport placement requires an accepted finite frame, clock, and nonzero cell.");
|
||||
}
|
||||
|
||||
body.Orientation = orientation;
|
||||
body.SnapToCell(placement.CellId, placement.Position, cellLocalPosition);
|
||||
body.LastUpdateTime = gameTime;
|
||||
|
||||
body.ContactPlaneValid = placement.InContact;
|
||||
if (placement.InContact)
|
||||
{
|
||||
body.ContactPlane = placement.ContactPlane;
|
||||
body.ContactPlaneCellId = placement.ContactPlaneCellId;
|
||||
body.ContactPlaneIsWater = placement.ContactPlaneIsWater;
|
||||
}
|
||||
else
|
||||
{
|
||||
body.ContactPlaneCellId = 0u;
|
||||
body.ContactPlaneIsWater = false;
|
||||
}
|
||||
|
||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
body,
|
||||
placement.InContact,
|
||||
placement.OnWalkable,
|
||||
placement.CollisionNormalValid,
|
||||
placement.CollisionNormal,
|
||||
previousContact,
|
||||
previousOnWalkable,
|
||||
remote.HitGround,
|
||||
remote.LeaveGround);
|
||||
remote.Airborne = !body.OnWalkable;
|
||||
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector3 value) =>
|
||||
float.IsFinite(value.X)
|
||||
&& float.IsFinite(value.Y)
|
||||
&& float.IsFinite(value.Z);
|
||||
|
||||
}
|
||||
|
|
@ -135,6 +135,19 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
|
|||
/// registry.</summary>
|
||||
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>
|
||||
/// A Hidden object remains logically alive but is removed from the cell
|
||||
/// lookup/interaction surface. Retail's DetectionManager sends
|
||||
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
|
||||
/// not ported yet, so the App bridges the same unavailability edge through
|
||||
/// the existing target fan-out. The non-Ok update tears down watcher
|
||||
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
|
||||
/// UnHide re-subscription receives a fresh initial snapshot. The object's
|
||||
/// own watcher role is deliberately preserved.
|
||||
/// </summary>
|
||||
public void NotifyHidden() =>
|
||||
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
|
||||
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
|
||||
/// (drop this entity's OWN subscription) then
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability);
|
||||
if (!ApplyParentWorldPose(child.Entity, parentWorld))
|
||||
return false;
|
||||
ApplyParentDrawVisibility(child.Entity, parent);
|
||||
child.Entity.ParentCellId = parent.ParentCellId;
|
||||
PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
|
||||
if (parent.ParentCellId is { } parentCellId)
|
||||
|
|
@ -317,6 +318,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
if (entity is null)
|
||||
return false;
|
||||
ApplyParentWorldPose(entity, parentWorld);
|
||||
ApplyParentDrawVisibility(entity, parentEntity);
|
||||
entity.ParentCellId = parentCellId;
|
||||
entity.ApplyAppearance(
|
||||
pose.AttachedParts,
|
||||
|
|
@ -338,6 +340,14 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
pose.AttachedParts,
|
||||
scale,
|
||||
entity);
|
||||
if (_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord)
|
||||
&& (parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
|
||||
{
|
||||
// A child attached while its parent is already Hidden inherits
|
||||
// the same direct child NoDraw mutation retail applies from
|
||||
// CPhysicsObj::set_hidden (0x00514C60).
|
||||
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
|
||||
}
|
||||
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
|
||||
Console.WriteLine(
|
||||
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
||||
|
|
@ -376,6 +386,18 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preserve retail attachment-tree draw inheritance after adapting every
|
||||
/// child CPhysicsObj into an independent retained draw entry. This does not
|
||||
/// alter the child's own NoDraw state; parent-first ticking propagates an
|
||||
/// ancestor's suppression through arbitrarily deep attachment chains.
|
||||
/// </summary>
|
||||
internal static void ApplyParentDrawVisibility(WorldEntity child, WorldEntity parent)
|
||||
{
|
||||
child.IsAncestorDrawVisible =
|
||||
parent.IsDrawVisible && parent.IsAncestorDrawVisible;
|
||||
}
|
||||
|
||||
private static bool TryDecomposeWorldPose(
|
||||
Matrix4x4 world,
|
||||
out Vector3 position,
|
||||
|
|
@ -425,6 +447,20 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::set_hidden</c> walks the direct CHILDLIST and
|
||||
/// sets/clears each child's NoDraw bit before changing root collision/cell
|
||||
/// presentation. Descendants are not recursively rewritten here.
|
||||
/// </summary>
|
||||
public void SetDirectChildrenNoDraw(uint parentGuid, bool noDraw)
|
||||
{
|
||||
foreach (AttachedChild child in _attachedByChild.Values)
|
||||
{
|
||||
if (child.ParentGuid == parentGuid)
|
||||
_liveEntities.SetAttachedChildNoDraw(child.ChildGuid, noDraw);
|
||||
}
|
||||
}
|
||||
|
||||
private void ResolveRelations(uint childGuid)
|
||||
{
|
||||
Relations.Resolve(
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ public sealed class GameWindow : IDisposable
|
|||
// once the injected shared helpers exist as method groups (GetSetupCylinder,
|
||||
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
|
||||
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
|
||||
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
||||
// Step 7 projectile presentation. The controller owns no identity map;
|
||||
// each runtime component is stored on the canonical LiveEntityRecord.
|
||||
private AcDream.App.Physics.ProjectileController? _projectileController;
|
||||
|
|
@ -460,10 +461,9 @@ public sealed class GameWindow : IDisposable
|
|||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class RemoteMotion :
|
||||
AcDream.App.World.ILiveEntityRemoteMotionRuntime,
|
||||
AcDream.App.World.ILiveEntityCanonicalCellConsumer // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
||||
AcDream.App.World.ILiveEntityRemotePlacementRuntime // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
||||
{
|
||||
public AcDream.Core.Physics.PhysicsBody Body;
|
||||
public AcDream.Core.Physics.PhysicsBody Body { get; }
|
||||
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
|
||||
|
||||
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
|
||||
|
|
@ -613,6 +613,36 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition
|
||||
{
|
||||
get => LastServerPos;
|
||||
set => LastServerPos = value;
|
||||
}
|
||||
|
||||
double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime
|
||||
{
|
||||
get => LastServerPosTime;
|
||||
set => LastServerPosTime = value;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition
|
||||
{
|
||||
get => LastShadowSyncPos;
|
||||
set => LastShadowSyncPos = value;
|
||||
}
|
||||
|
||||
bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne
|
||||
{
|
||||
get => Airborne;
|
||||
set => Airborne = value;
|
||||
}
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() =>
|
||||
Movement.HitGround();
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() =>
|
||||
Motion.LeaveGround();
|
||||
|
||||
/// <summary>
|
||||
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
|
||||
/// arc in flight). Set when a 0xF74E VectorUpdate arrives with
|
||||
|
|
@ -857,6 +887,7 @@ public sealed class GameWindow : IDisposable
|
|||
// from the animation pipeline flip the matching LightSource.IsLit.
|
||||
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
||||
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
|
||||
private AcDream.App.World.LiveEntityPresentationController? _liveEntityPresentation;
|
||||
|
||||
// #188 — TransparentPartHook fires from the animation pipeline drive
|
||||
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
|
||||
|
|
@ -2173,7 +2204,8 @@ public sealed class GameWindow : IDisposable
|
|||
playerCellId: () => _playerController?.CellId ?? 0u,
|
||||
selectedGuid: () => _selection.SelectedObjectId,
|
||||
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
|
||||
uiLocked: () => _persistedGameplay.LockUI);
|
||||
uiLocked: () => _persistedGameplay.LockUI,
|
||||
playerEntities: _entitiesByServerGuid);
|
||||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||||
_retailChatVm = retailChatVm;
|
||||
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
|
||||
|
|
@ -2444,7 +2476,6 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
|
||||
};
|
||||
|
||||
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
|
||||
_liveEntities,
|
||||
_effectPoses,
|
||||
|
|
@ -2479,9 +2510,34 @@ public sealed class GameWindow : IDisposable
|
|||
_entityEffects = entityEffects;
|
||||
entityEffects.DiagnosticSink = message =>
|
||||
Console.Error.WriteLine($"vfx: {message}");
|
||||
_liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController(
|
||||
_liveEntities,
|
||||
_physicsEngine.ShadowObjects,
|
||||
entityEffects.PlayTyped,
|
||||
_equippedChildRenderer.SetDirectChildrenNoDraw,
|
||||
ClearTargetForHiddenEntity,
|
||||
() => (_liveCenterX, _liveCenterY));
|
||||
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
|
||||
_physicsEngine,
|
||||
_liveEntities,
|
||||
GetSetupCylinder,
|
||||
CellLocalForSeed,
|
||||
(entity, remote, cellId) => _remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||
entity.Id,
|
||||
remote,
|
||||
_liveCenterX,
|
||||
_liveCenterY,
|
||||
cellId),
|
||||
(guid, generation, deferShadowRestore) =>
|
||||
_liveEntityPresentation.CompleteAuthoritativePlacement(
|
||||
guid,
|
||||
generation,
|
||||
deferShadowRestore),
|
||||
(guid, generation) =>
|
||||
_liveEntityPresentation.BeginAuthoritativePlacement(guid, generation));
|
||||
_equippedChildRenderer.EntityReady += guid =>
|
||||
{
|
||||
entityEffects.OnLiveEntityReady(guid);
|
||||
CompleteLiveEntityReady(guid);
|
||||
};
|
||||
_equippedChildRenderer.ProjectionPoseReady += guid =>
|
||||
_liveEntityLights.OnAttachedPoseReady(guid);
|
||||
|
|
@ -2704,16 +2760,33 @@ public sealed class GameWindow : IDisposable
|
|||
_equippedChildRenderer?.Clear();
|
||||
Objects.Clear();
|
||||
_selection.Reset();
|
||||
_pendingPostArrivalAction = null;
|
||||
try
|
||||
{
|
||||
_liveEntities?.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// F754/F755 can precede CreateObject, so the mixed pending FIFO
|
||||
// may contain owners with no LiveEntityRecord to tear down.
|
||||
_entityEffects?.ClearNetworkState();
|
||||
_animationHookFrames?.Clear();
|
||||
try
|
||||
{
|
||||
// A pending teleport is operational state outside the live
|
||||
// record. Clear it independently even if a resource callback
|
||||
// failed while the canonical runtime was draining.
|
||||
_remoteTeleportController?.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
// F754/F755 can precede CreateObject, so the mixed pending FIFO
|
||||
// may contain owners with no LiveEntityRecord to tear down.
|
||||
_entityEffects?.ClearNetworkState();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_animationHookFrames?.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4096,7 +4169,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Renderer, script owner, optional animation owner, collision body,
|
||||
// and effect profile are now all installed. Only at this boundary may
|
||||
// the mixed pre-Create F754/F755 FIFO replay against the local ID.
|
||||
_entityEffects?.OnLiveEntityReady(spawn.Guid);
|
||||
CompleteLiveEntityReady(spawn.Guid);
|
||||
|
||||
// Dump a summary periodically so we can see drop breakdowns without
|
||||
// waiting for a graceful shutdown.
|
||||
|
|
@ -4468,7 +4541,9 @@ public sealed class GameWindow : IDisposable
|
|||
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
|
||||
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
|
||||
|
||||
uint state = spawn.PhysicsState ?? 0u;
|
||||
uint state = _liveEntities?.TryGetRecord(spawn.Guid, out LiveEntityRecord liveRecord) == true
|
||||
? (uint)liveRecord.FinalPhysicsState
|
||||
: spawn.PhysicsState ?? 0u;
|
||||
|
||||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||||
entityId: entity.Id,
|
||||
|
|
@ -4596,6 +4671,13 @@ public sealed class GameWindow : IDisposable
|
|||
uint cellId,
|
||||
bool force = false)
|
||||
{
|
||||
if (_liveEntities?.IsHidden(_playerServerGuid) == true)
|
||||
{
|
||||
_physicsEngine.ShadowObjects.Suspend(playerEntity.Id);
|
||||
_lastLocalPlayerShadow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force
|
||||
&& _lastLocalPlayerShadow is { } last
|
||||
&& last.CellId == cellId
|
||||
|
|
@ -4836,17 +4918,20 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
|
||||
{
|
||||
// CObjCell::hide_object removes Hidden objects from the lookup surface
|
||||
// used to establish new target/voyeur relationships. Existing
|
||||
// subscriptions are cleared by LiveEntityPresentationController.
|
||||
if (!_visibleEntitiesByServerGuid.ContainsKey(id))
|
||||
return null;
|
||||
if (_physicsHosts.TryGetValue(id, out var existing))
|
||||
return existing;
|
||||
if (!_entitiesByServerGuid.ContainsKey(id))
|
||||
return null;
|
||||
|
||||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
var minimal = new EntityPhysicsHost(
|
||||
id,
|
||||
getPosition: () => new AcDream.Core.Physics.Position(
|
||||
0u,
|
||||
_entitiesByServerGuid.TryGetValue(id, out var e)
|
||||
_visibleEntitiesByServerGuid.TryGetValue(id, out var e)
|
||||
? e.Position : System.Numerics.Vector3.Zero,
|
||||
System.Numerics.Quaternion.Identity),
|
||||
getVelocity: () => System.Numerics.Vector3.Zero, // static target
|
||||
|
|
@ -4914,20 +4999,63 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (host is null)
|
||||
return;
|
||||
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var tgtEnt))
|
||||
if (_liveEntities is not { } liveEntities
|
||||
|| !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt))
|
||||
return;
|
||||
var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
|
||||
host.PositionManager.StickTo(targetGuid, radius, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes retail object construction in order: register the effect
|
||||
/// owner, apply constructor/PhysicsDesc state transitions, then replay
|
||||
/// SmartBox's queued F754/F755 blobs.
|
||||
/// </summary>
|
||||
private void CompleteLiveEntityReady(uint serverGuid)
|
||||
{
|
||||
if (_entityEffects?.PrepareLiveEntityOwner(serverGuid) != true)
|
||||
return;
|
||||
_liveEntityPresentation?.OnLiveEntityReady(serverGuid);
|
||||
_entityEffects.ReplayPendingForLiveEntity(serverGuid);
|
||||
}
|
||||
|
||||
private void ClearTargetForHiddenEntity(uint serverGuid)
|
||||
{
|
||||
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
||||
&& pendingGuid == serverGuid)
|
||||
_pendingPostArrivalAction = null;
|
||||
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||
AcDream.Core.Selection.SelectionChangeReason.Cleared);
|
||||
}
|
||||
|
||||
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost)
|
||||
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
|
||||
hiddenEntityHost.NotifyHidden();
|
||||
}
|
||||
|
||||
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
|
||||
{
|
||||
_entityEffects?.OnLiveEntityUnregistered(record);
|
||||
uint serverGuid = record.ServerGuid;
|
||||
var cleanups = new List<Action>
|
||||
{
|
||||
() => _liveEntityPresentation?.Forget(record),
|
||||
() => _entityEffects?.OnLiveEntityUnregistered(record),
|
||||
() => _remoteTeleportController?.Forget(serverGuid),
|
||||
};
|
||||
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
||||
&& pendingGuid == serverGuid)
|
||||
_pendingPostArrivalAction = null;
|
||||
|
||||
if (record.WorldEntity is not { } existingEntity)
|
||||
{
|
||||
AcDream.App.World.LiveEntityTeardown.Run(cleanups);
|
||||
return;
|
||||
}
|
||||
|
||||
uint serverGuid = record.ServerGuid;
|
||||
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
|
||||
// independently (r3-port-plan §4): the manager's (each pending
|
||||
// animation fires MotionDone(success:false) → the bound interp pops
|
||||
|
|
@ -4935,9 +5063,9 @@ public sealed class GameWindow : IDisposable
|
|||
// MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade
|
||||
// relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30).
|
||||
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
|
||||
aeGone.Sequencer?.Manager.HandleExitWorld();
|
||||
cleanups.Add(() => aeGone.Sequencer?.Manager.HandleExitWorld());
|
||||
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
|
||||
rmGone.Movement.HandleExitWorld();
|
||||
cleanups.Add(rmGone.Movement.HandleExitWorld);
|
||||
// R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this
|
||||
// entity that it left the world (TargetManager::NotifyVoyeurOfEvent
|
||||
// ExitWorld) — a watcher moving-to/stuck-to this entity drops the
|
||||
|
|
@ -4956,31 +5084,35 @@ public sealed class GameWindow : IDisposable
|
|||
// (ExitWorld) tells the entities watching IT. Without the first
|
||||
// two, a despawning attacker leaves a dead voyeur entry on its
|
||||
// target until send-failure pruning.
|
||||
ephGone.PositionManager.UnStick();
|
||||
ephGone.ClearTarget();
|
||||
ephGone.NotifyExitWorld();
|
||||
cleanups.Add(ephGone.PositionManager.UnStick);
|
||||
cleanups.Add(ephGone.ClearTarget);
|
||||
cleanups.Add(ephGone.NotifyExitWorld);
|
||||
}
|
||||
_physicsHosts.Remove(serverGuid);
|
||||
_animatedEntities.Remove(existingEntity.Id);
|
||||
_classificationCache.InvalidateEntity(existingEntity.Id);
|
||||
cleanups.Add(() => _physicsHosts.Remove(serverGuid));
|
||||
cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id));
|
||||
cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id));
|
||||
|
||||
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
||||
// clear using the same guid the next spawn/update would use.
|
||||
_remoteDeadReckon.Remove(serverGuid);
|
||||
_remoteLastMove.Remove(serverGuid);
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
cleanups.Add(() => _remoteDeadReckon.Remove(serverGuid));
|
||||
cleanups.Add(() => _remoteLastMove.Remove(serverGuid));
|
||||
cleanups.Add(() =>
|
||||
{
|
||||
_selection.Clear(
|
||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
});
|
||||
|
||||
_translucencyFades.ClearEntity(existingEntity.Id); // #188
|
||||
LeaveWorldLiveEntityRuntimeComponents(record);
|
||||
cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188
|
||||
cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record));
|
||||
// Logical teardown ends the retained shadow payload too. The weaker
|
||||
// pickup/parent path stops after Suspend so re-entry can restore it.
|
||||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
||||
_liveEntityLights?.Forget(existingEntity.Id);
|
||||
cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id));
|
||||
cleanups.Add(() => _liveEntityLights?.Forget(existingEntity.Id));
|
||||
AcDream.App.World.LiveEntityTeardown.Run(cleanups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -5075,7 +5207,8 @@ public sealed class GameWindow : IDisposable
|
|||
// MoveToPosition at the wire origin (§2f).
|
||||
if (update.MotionState.MovementType == 6
|
||||
&& path.TargetGuid is { } tgtGuid
|
||||
&& _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
|
||||
&& _liveEntities is { } liveMoveEntities
|
||||
&& liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt))
|
||||
{
|
||||
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
|
||||
ms.ObjectId = tgtGuid;
|
||||
|
|
@ -5115,7 +5248,8 @@ public sealed class GameWindow : IDisposable
|
|||
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
|
||||
if (update.MotionState.MovementType == 8
|
||||
&& turnPath.TargetGuid is { } turnTgt
|
||||
&& _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
|
||||
&& _liveEntities is { } liveTurnEntities
|
||||
&& liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt))
|
||||
{
|
||||
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
|
||||
ms.ObjectId = turnTgt;
|
||||
|
|
@ -5761,15 +5895,25 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
|
||||
{
|
||||
if (!_liveEntities!.TryApplyState(parsed, out _)) return;
|
||||
if (!_liveEntities!.TryApplyState(parsed, out _, out _)) return;
|
||||
|
||||
if (!_liveEntities.TryGetRecord(parsed.Guid, out LiveEntityRecord record))
|
||||
return;
|
||||
|
||||
// Retail set_state order: Lighting, NoDraw, then Hidden. The live
|
||||
// runtime already committed the raw/final bits and draw visibility;
|
||||
// apply the ordered owners before updating motion/collision consumers.
|
||||
_liveEntityLights?.OnStateChanged(parsed.Guid);
|
||||
_liveEntityPresentation?.OnStateAccepted(parsed.Guid);
|
||||
|
||||
_projectileController?.ApplyAuthoritativeState(
|
||||
parsed.Guid,
|
||||
(AcDream.Core.Physics.PhysicsStateFlags)parsed.PhysicsState,
|
||||
record.FinalPhysicsState,
|
||||
_physicsScriptGameTime,
|
||||
_liveCenterX,
|
||||
_liveCenterY);
|
||||
_liveEntityLights?.OnStateChanged(parsed.Guid);
|
||||
if (parsed.Guid == _playerServerGuid)
|
||||
_playerController?.ApplyPhysicsState(record.FinalPhysicsState);
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
|
||||
|
||||
|
|
@ -5782,11 +5926,9 @@ public sealed class GameWindow : IDisposable
|
|||
// ACE flipped the ETHEREAL bit.
|
||||
uint registryKey = entity.Id;
|
||||
|
||||
_physicsEngine.ShadowObjects.UpdatePhysicsState(registryKey, parsed.PhysicsState);
|
||||
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
|
||||
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} raw=0x{parsed.PhysicsState:X8} final=0x{(uint)record.FinalPhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
|
||||
}
|
||||
|
||||
private void ApplyServerControlledVelocityCycle(
|
||||
|
|
@ -5995,6 +6137,14 @@ public sealed class GameWindow : IDisposable
|
|||
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||
DumpMovementTruthServerEcho(update, worldPos);
|
||||
|
||||
bool remoteHardTeleport = update.Guid != _playerServerGuid
|
||||
&& timestamps.TeleportHookRequired;
|
||||
bool remotePlacementRequired = update.Guid != _playerServerGuid
|
||||
&& (remoteHardTeleport
|
||||
|| _remoteTeleportController?.HasPending(update.Guid) == true);
|
||||
if (remoteHardTeleport)
|
||||
RunRemoteTeleportHook(update.Guid, entity.Id);
|
||||
|
||||
// Missiles reconcile the same predicted PhysicsBody in place. The
|
||||
// timestamp gate above already rejected stale corrections; returning
|
||||
// here prevents the generic remote locomotion path from allocating a
|
||||
|
|
@ -6013,6 +6163,13 @@ public sealed class GameWindow : IDisposable
|
|||
_liveCenterY) == true)
|
||||
return;
|
||||
|
||||
if (remotePlacementRequired)
|
||||
{
|
||||
_remoteTeleportController!.BeginPlacement(
|
||||
update.Guid,
|
||||
acceptedSpawn.InstanceSequence);
|
||||
}
|
||||
|
||||
// Capture the pre-update render position for the soft-snap residual
|
||||
// calculation below. Assign entity.Position to the server truth up
|
||||
// front; if we then compute a snap residual, we restore the rendered
|
||||
|
|
@ -6096,6 +6253,50 @@ public sealed class GameWindow : IDisposable
|
|||
rmState.Body.Position = worldPos;
|
||||
}
|
||||
|
||||
// Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a
|
||||
// fresh TELEPORT_TS, or the first placement of a cell-less body,
|
||||
// runs teleport_hook and SetPosition(0x1012) BEFORE the contact
|
||||
// test. In particular, an airborne UP cannot veto or undo this
|
||||
// authoritative destination. Do not pre-clear velocity or invent
|
||||
// grounded flags here: retail SetPosition derives contact from its
|
||||
// transition, while MoveOrTeleport does not consume arg5/arg6.
|
||||
if (remotePlacementRequired)
|
||||
{
|
||||
double teleportTime =
|
||||
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
bool projectionVisible = _liveEntities.TryGetRecord(
|
||||
update.Guid,
|
||||
out LiveEntityRecord teleportRecord)
|
||||
&& teleportRecord.IsSpatiallyVisible;
|
||||
var placement = _remoteTeleportController!.TryApply(
|
||||
rmState,
|
||||
entity,
|
||||
worldPos,
|
||||
p.LandblockId,
|
||||
new System.Numerics.Vector3(
|
||||
p.PositionX,
|
||||
p.PositionY,
|
||||
p.PositionZ),
|
||||
rot,
|
||||
teleportTime,
|
||||
projectionVisible,
|
||||
acceptedSpawn.InstanceSequence,
|
||||
acceptedSpawn.PositionSequence);
|
||||
if (!placement.Applied)
|
||||
{
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
entity.ParentCellId = rmState.CellId;
|
||||
entity.Rotation = rmState.Body.Orientation;
|
||||
if (rmState.CellId != 0)
|
||||
_liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId);
|
||||
return;
|
||||
}
|
||||
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
entity.Rotation = rmState.Body.Orientation;
|
||||
return;
|
||||
}
|
||||
|
||||
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
|
||||
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport
|
||||
// (acclient @ 0x00516330) — airborne no-op, far-snap, near
|
||||
|
|
@ -6284,7 +6485,7 @@ public sealed class GameWindow : IDisposable
|
|||
// DR tick) and for no-Sequencer players. rmState.CellId is the server
|
||||
// cell adopted above (:5735). The per-tick loop keeps it current
|
||||
// between UPs (movement-gated).
|
||||
if (rmState.CellId != 0)
|
||||
if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid))
|
||||
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
|
|
@ -6514,7 +6715,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
|
||||
// the per-tick loop skips). The per-tick loop keeps it current between
|
||||
// UPs, movement-gated. rmState.CellId is the server cell adopted above.
|
||||
if (rmState.CellId != 0)
|
||||
if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid))
|
||||
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
||||
|
||||
|
|
@ -8777,7 +8978,10 @@ public sealed class GameWindow : IDisposable
|
|||
// (which runs after this block). Replaces the AP-79 player poll.
|
||||
_playerHost?.HandleTargetting();
|
||||
|
||||
var result = _playerController.Update((float)dt, input);
|
||||
bool localPlayerHidden = _liveEntities?.IsHidden(_playerServerGuid) == true;
|
||||
var result = localPlayerHidden
|
||||
? _playerController.TickHidden((float)dt)
|
||||
: _playerController.Update((float)dt, input);
|
||||
|
||||
// Update the player entity's position + rotation so it renders at
|
||||
// the physics-resolved location each frame.
|
||||
|
|
@ -8787,7 +8991,8 @@ public sealed class GameWindow : IDisposable
|
|||
pe.ParentCellId = result.CellId;
|
||||
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
|
||||
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
|
||||
SyncLocalPlayerShadow(pe, result.CellId);
|
||||
if (!localPlayerHidden)
|
||||
SyncLocalPlayerShadow(pe, result.CellId);
|
||||
|
||||
// Move the player entity to its current landblock in GpuWorldState
|
||||
// so it doesn't get frustum-culled when the player walks away from
|
||||
|
|
@ -8858,7 +9063,7 @@ public sealed class GameWindow : IDisposable
|
|||
trackedTargetPoint: trackedCombatTarget);
|
||||
|
||||
// Send outbound movement messages to the live server.
|
||||
if (_liveSession is not null)
|
||||
if (_liveSession is not null && !localPlayerHidden)
|
||||
{
|
||||
// Convert world position back to AC wire coordinates.
|
||||
// World origin is _liveCenterX/_liveCenterY; each landblock is 192 units.
|
||||
|
|
@ -9274,6 +9479,14 @@ public sealed class GameWindow : IDisposable
|
|||
// Re-publish without advancing so an extra render between updates still
|
||||
// retains retail's animation-hook versus object-hook phase barrier.
|
||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||
if (_liveEntities is { } liveEntities)
|
||||
{
|
||||
_remotePhysicsUpdater.TickHiddenEntities(
|
||||
liveEntities,
|
||||
_playerServerGuid,
|
||||
(float)deltaSeconds,
|
||||
entity => _effectPoses.UpdateRoot(entity));
|
||||
}
|
||||
_projectileController?.Tick(
|
||||
_physicsScriptGameTime,
|
||||
_liveCenterX,
|
||||
|
|
@ -10386,6 +10599,22 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void RunRemoteTeleportHook(uint serverGuid, uint localEntityId)
|
||||
{
|
||||
_remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote);
|
||||
EntityPhysicsHost? host = _physicsHosts.TryGetValue(serverGuid, out var registered)
|
||||
? registered as EntityPhysicsHost
|
||||
: remote?.Host;
|
||||
AcDream.App.Physics.RemoteTeleportHook.Execute(
|
||||
new AcDream.App.Physics.RemoteTeleportHookActions(
|
||||
CancelMoveTo: error => remote?.Movement.CancelMoveTo(error),
|
||||
UnStick: () => host?.PositionManager.UnStick(),
|
||||
StopInterpolating: () => remote?.Interp.Clear(),
|
||||
UnConstrain: () => host?.PositionManager.UnConstrain(),
|
||||
NotifyTeleported: () => host?.NotifyTeleported(),
|
||||
ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4: advance every animated entity's frame counter by
|
||||
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
|
||||
|
|
@ -10433,6 +10662,21 @@ public sealed class GameWindow : IDisposable
|
|||
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
|
||||
continue;
|
||||
|
||||
// Retail UpdatePositionInternal keeps a narrow Hidden path alive:
|
||||
// no PartArray animation and no physics integration, but
|
||||
// PositionManager::adjust_offset plus the manager tail still run.
|
||||
// Scripts and particles tick in their shared passes below.
|
||||
if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true)
|
||||
{
|
||||
// Hidden suppresses the PartArray/mesh, not the effect owner.
|
||||
// Remote PositionManager composition already ran in the
|
||||
// unconditional live-entity pass; publish again for the local
|
||||
// player and for retained objects without remote motion.
|
||||
// Frozen part-local poses remain unchanged until UnHide.
|
||||
_effectPoses.UpdateRoot(ae.Entity);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||||
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
||||
// entities; without integration, remote chars jitter-hop between
|
||||
|
|
@ -13570,6 +13814,8 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
|
||||
if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord playerRecord) == true)
|
||||
_playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
|
||||
|
||||
// R4-V5: the local player's verbatim MoveToManager — same seam
|
||||
// wiring shape as EnsureRemoteMotionBindings, with three
|
||||
|
|
@ -14192,6 +14438,9 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
_liveEntityLights?.Dispose();
|
||||
_liveEntityLights = null;
|
||||
_liveEntityPresentation?.Dispose();
|
||||
_remoteTeleportController?.Dispose();
|
||||
_remoteTeleportController = null;
|
||||
_entityEffects?.ClearNetworkState();
|
||||
_animationHookFrames?.Clear();
|
||||
_effectPoses.Clear();
|
||||
|
|
|
|||
|
|
@ -103,6 +103,19 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
/// original mixed F754/F755 order.
|
||||
/// </summary>
|
||||
public bool OnLiveEntityReady(uint serverGuid)
|
||||
{
|
||||
if (!PrepareLiveEntityOwner(serverGuid))
|
||||
return false;
|
||||
ReplayPendingForLiveEntity(serverGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the local effect owner without replaying pre-create packets.
|
||||
/// Production uses this narrow barrier so constructor/set_description
|
||||
/// state effects can run before SmartBox replays queued network blobs.
|
||||
/// </summary>
|
||||
public bool PrepareLiveEntityOwner(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity
|
||||
|
|
@ -116,7 +129,15 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
_profilesByLocalId[entity.Id] = profile;
|
||||
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
||||
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
||||
TryReplayPending(serverGuid, entity.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary>
|
||||
public bool ReplayPendingForLiveEntity(uint serverGuid)
|
||||
{
|
||||
if (!TryGetReadyLocalId(serverGuid, out uint localId))
|
||||
return false;
|
||||
TryReplayPending(serverGuid, localId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -701,6 +701,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
foreach (var animatedId in animatedEntityIds)
|
||||
{
|
||||
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
|
||||
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
|
||||
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||
if (!EntityMatchesSet(entity, set)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
|
@ -722,6 +723,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
foreach (var entity in entry.Entities)
|
||||
{
|
||||
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
|
||||
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||
if (!EntityMatchesSet(entity, set)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ public sealed class GpuWorldState
|
|||
// list, so rebuilding it replaces the reference atomically.
|
||||
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
|
||||
private readonly HashSet<uint> _visibleLiveGuids = new();
|
||||
private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new();
|
||||
private bool _dispatchingVisibilityTransitions;
|
||||
|
||||
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
|
||||
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
|
||||
|
|
@ -282,6 +284,10 @@ public sealed class GpuWorldState
|
|||
// and it couldn't reach a pending entity). Re-appending routes the entity
|
||||
// to _loaded (drawn) when its landblock is loaded, or back to pending to
|
||||
// await AddLandblock otherwise.
|
||||
// Remove + place is one spatial transaction. Rebuilding between the
|
||||
// two operations exposes a false/true implementation pulse for a
|
||||
// loaded-to-loaded move and lets reentrant visibility callbacks observe
|
||||
// a state that never exists at the LiveEntityRuntime boundary.
|
||||
RemoveEntityFromAllBuckets(entity);
|
||||
PlaceLiveEntityProjection(canonical, entity);
|
||||
}
|
||||
|
|
@ -307,7 +313,6 @@ public sealed class GpuWorldState
|
|||
if (j != i) newList.Add(entities[j]);
|
||||
_loaded[kvp.Key] = new LoadedLandblock(
|
||||
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
|
||||
RebuildFlatView();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -510,6 +515,7 @@ public sealed class GpuWorldState
|
|||
bucket.Add(entity);
|
||||
if (probePersistent)
|
||||
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)");
|
||||
RebuildFlatView();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -624,21 +630,48 @@ public sealed class GpuWorldState
|
|||
_flatEntities
|
||||
.Where(entity => entity.ServerGuid != 0)
|
||||
.Select(entity => entity.ServerGuid));
|
||||
foreach (uint guid in _visibleLiveGuids)
|
||||
{
|
||||
if (!nowVisible.Contains(guid))
|
||||
LiveProjectionVisibilityChanged?.Invoke(guid, false);
|
||||
}
|
||||
foreach (uint guid in nowVisible)
|
||||
{
|
||||
if (!_visibleLiveGuids.Contains(guid))
|
||||
LiveProjectionVisibilityChanged?.Invoke(guid, true);
|
||||
}
|
||||
uint[] becameHidden = _visibleLiveGuids
|
||||
.Where(guid => !nowVisible.Contains(guid))
|
||||
.ToArray();
|
||||
uint[] becameVisible = nowVisible
|
||||
.Where(guid => !_visibleLiveGuids.Contains(guid))
|
||||
.ToArray();
|
||||
|
||||
// Commit the new spatial truth before notifying observers. A
|
||||
// visibility callback may synchronously rebucket/withdraw the same
|
||||
// object (deferred teleport rollback); those nested transitions must
|
||||
// compare against this committed state rather than the stale prior set.
|
||||
_visibleLiveGuids.Clear();
|
||||
_visibleLiveGuids.UnionWith(nowVisible);
|
||||
foreach (uint guid in becameHidden)
|
||||
_visibilityTransitions.Enqueue((guid, false));
|
||||
foreach (uint guid in becameVisible)
|
||||
_visibilityTransitions.Enqueue((guid, true));
|
||||
DrainVisibilityTransitions();
|
||||
if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
|
||||
}
|
||||
|
||||
private void DrainVisibilityTransitions()
|
||||
{
|
||||
if (_dispatchingVisibilityTransitions)
|
||||
return;
|
||||
|
||||
_dispatchingVisibilityTransitions = true;
|
||||
try
|
||||
{
|
||||
while (_visibilityTransitions.TryDequeue(out var transition))
|
||||
{
|
||||
LiveProjectionVisibilityChanged?.Invoke(
|
||||
transition.ServerGuid,
|
||||
transition.Visible);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dispatchingVisibilityTransitions = false;
|
||||
}
|
||||
}
|
||||
|
||||
// TEMP (#138-B): persistent guids currently present in the drawn flat
|
||||
// view, for EntityVanishProbe transition logging. Strip with the probe.
|
||||
private readonly HashSet<uint> _persistentInFlatProbe = new();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public sealed class RadarSnapshotProvider
|
|||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities;
|
||||
private readonly IReadOnlyDictionary<uint, WorldEntity> _playerEntities;
|
||||
private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<float> _playerYawRadians;
|
||||
|
|
@ -39,10 +40,12 @@ public sealed class RadarSnapshotProvider
|
|||
Func<uint?> selectedGuid,
|
||||
Func<bool> coordinatesOnRadar,
|
||||
Func<bool> uiLocked,
|
||||
Func<uint, RadarRelationshipTraits>? relationshipFor = null)
|
||||
Func<uint, RadarRelationshipTraits>? relationshipFor = null,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? playerEntities = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
|
||||
_playerEntities = playerEntities ?? worldEntities;
|
||||
_spawns = spawns ?? throw new ArgumentNullException(nameof(spawns));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians));
|
||||
|
|
@ -57,7 +60,7 @@ public sealed class RadarSnapshotProvider
|
|||
{
|
||||
bool uiLocked = _uiLocked();
|
||||
uint playerGuid = _playerGuid();
|
||||
if (playerGuid == 0u || !_worldEntities.TryGetValue(playerGuid, out var playerEntity))
|
||||
if (playerGuid == 0u || !_playerEntities.TryGetValue(playerGuid, out var playerEntity))
|
||||
return UiRadarSnapshot.Empty with { UiLocked = uiLocked };
|
||||
|
||||
float heading = MoveToMath.HeadingFromYaw(_playerYawRadians());
|
||||
|
|
|
|||
|
|
@ -319,7 +319,10 @@ public sealed class InboundPhysicsStateController
|
|||
update.TeleportSequence,
|
||||
update.ForcePositionSequence,
|
||||
isLocalPlayer);
|
||||
timestamps = Current(gate);
|
||||
timestamps = Current(
|
||||
gate,
|
||||
teleportAdvanced: disposition is PositionTimestampDisposition.Apply
|
||||
&& advancesTeleport);
|
||||
if (disposition is PositionTimestampDisposition.Rejected)
|
||||
{
|
||||
accepted = MirrorGateTimestamps(old, gate);
|
||||
|
|
@ -438,11 +441,14 @@ public sealed class InboundPhysicsStateController
|
|||
0,
|
||||
spawn.InstanceSequence);
|
||||
|
||||
private static AcceptedPhysicsTimestamps Current(PhysicsTimestampGate gate) => new(
|
||||
private static AcceptedPhysicsTimestamps Current(
|
||||
PhysicsTimestampGate gate,
|
||||
bool teleportAdvanced = false) => new(
|
||||
gate.InstanceTimestamp,
|
||||
gate.ServerControlledMoveTimestamp,
|
||||
gate.TeleportTimestamp,
|
||||
gate.ForcePositionTimestamp);
|
||||
gate.ForcePositionTimestamp,
|
||||
teleportAdvanced);
|
||||
|
||||
private static WorldSession.EntitySpawn MirrorGateTimestamps(
|
||||
WorldSession.EntitySpawn spawn,
|
||||
|
|
@ -622,7 +628,9 @@ public readonly record struct AcceptedPhysicsTimestamps(
|
|||
ushort Instance,
|
||||
ushort ServerControlledMove,
|
||||
ushort Teleport,
|
||||
ushort ForcePosition);
|
||||
ushort ForcePosition,
|
||||
bool TeleportAdvanced = false,
|
||||
bool TeleportHookRequired = false);
|
||||
|
||||
public readonly record struct CreateParentUpdate(
|
||||
uint ChildGuid,
|
||||
|
|
|
|||
282
src/AcDream.App/World/LiveEntityPresentationController.cs
Normal file
282
src/AcDream.App/World/LiveEntityPresentationController.cs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
using AcDream.App.Physics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the presentation/collision side effects of accepted retail
|
||||
/// PhysicsState transitions after the canonical live owner is ready.
|
||||
/// Logical ownership stays in <see cref="LiveEntityRuntime"/>; Hidden never
|
||||
/// unregisters scripts, particles, lights, meshes, or the live record.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ports <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>),
|
||||
/// <c>set_nodraw</c> (<c>0x0050FCA0</c>), and <c>set_hidden</c>
|
||||
/// (<c>0x00514C60</c>). Typed script ids are retail
|
||||
/// <c>PS_UnHide=0x75</c> and <c>PS_Hidden=0x76</c> from <c>acclient.h</c>.
|
||||
/// </remarks>
|
||||
public sealed class LiveEntityPresentationController : IDisposable
|
||||
{
|
||||
public const uint UnHideScriptType = 0x75u;
|
||||
public const uint HiddenScriptType = 0x76u;
|
||||
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ShadowObjectRegistry _shadows;
|
||||
private readonly Func<uint, uint, float, bool> _playTyped;
|
||||
private readonly Action<uint, bool> _setDirectChildrenNoDraw;
|
||||
private readonly Action<uint> _clearInvalidTarget;
|
||||
private readonly Func<(int X, int Y)> _liveCenter;
|
||||
private readonly Action<uint>? _onShadowRestored;
|
||||
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
|
||||
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
|
||||
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
|
||||
private bool _disposed;
|
||||
|
||||
public LiveEntityPresentationController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
ShadowObjectRegistry shadows,
|
||||
Func<uint, uint, float, bool> playTyped,
|
||||
Action<uint, bool>? setDirectChildrenNoDraw = null,
|
||||
Action<uint>? clearInvalidTarget = null,
|
||||
Func<(int X, int Y)>? liveCenter = null,
|
||||
Action<uint>? onShadowRestored = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||||
_playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped));
|
||||
_setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { });
|
||||
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
|
||||
_liveCenter = liveCenter ?? (() => (0, 0));
|
||||
_onShadowRestored = onShadowRestored;
|
||||
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the state-side-effect barrier after render/effect owners have
|
||||
/// registered, then drains constructor and pre-materialization transitions
|
||||
/// exactly once in accepted order.
|
||||
/// </summary>
|
||||
public bool OnLiveEntityReady(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is null
|
||||
|| !record.ResourcesRegistered)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_readyGenerationByGuid[serverGuid] = record.Generation;
|
||||
ApplyPendingTransitions(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
|
||||
public bool OnStateAccepted(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| !_readyGenerationByGuid.TryGetValue(serverGuid, out ushort generation)
|
||||
|| generation != record.Generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyPendingTransitions(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retains ownership of a collision shadow that another retail transition
|
||||
/// suspended while this incarnation's spatial projection is unavailable.
|
||||
/// Hidden/UnHide and teleport rollback intentionally converge on this one
|
||||
/// generation-scoped restoration marker.
|
||||
/// </summary>
|
||||
public bool DeferShadowRestore(uint serverGuid, ushort generation)
|
||||
=> CompleteAuthoritativePlacement(serverGuid, generation, deferShadowRestore: true);
|
||||
|
||||
/// <summary>
|
||||
/// Completes active placement ownership after its final frame/cell is
|
||||
/// known, either returning a stable deferred restore to presentation or
|
||||
/// clearing the suspension for an immediate controller-owned restore.
|
||||
/// </summary>
|
||||
public bool CompleteAuthoritativePlacement(
|
||||
uint serverGuid,
|
||||
ushort generation,
|
||||
bool deferShadowRestore)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.Generation != generation
|
||||
|| record.WorldEntity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_activePlacementGenerationByGuid.TryGetValue(
|
||||
serverGuid,
|
||||
out ushort activeGeneration)
|
||||
&& activeGeneration == generation)
|
||||
{
|
||||
_activePlacementGenerationByGuid.Remove(serverGuid);
|
||||
}
|
||||
|
||||
if (deferShadowRestore)
|
||||
_suspendedShadowGenerationByGuid[serverGuid] = generation;
|
||||
else if (_suspendedShadowGenerationByGuid.TryGetValue(
|
||||
serverGuid,
|
||||
out ushort deferredGeneration)
|
||||
&& deferredGeneration == generation)
|
||||
_suspendedShadowGenerationByGuid.Remove(serverGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transfers a deferred restore to a newer authoritative placement before
|
||||
/// that placement rebuckets and can become visible. The placement hands
|
||||
/// ownership back after it reaches a stable Hidden destination/rollback;
|
||||
/// this prevents an intervening UnHide from restoring an unresolved pose.
|
||||
/// </summary>
|
||||
public bool BeginAuthoritativePlacement(uint serverGuid, ushort generation)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.Generation != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_activePlacementGenerationByGuid[serverGuid] = generation;
|
||||
if (_suspendedShadowGenerationByGuid.TryGetValue(
|
||||
serverGuid,
|
||||
out ushort deferredGeneration)
|
||||
&& deferredGeneration == generation)
|
||||
{
|
||||
_suspendedShadowGenerationByGuid.Remove(serverGuid);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool HasDeferredShadowRestore(uint serverGuid) =>
|
||||
_suspendedShadowGenerationByGuid.ContainsKey(serverGuid);
|
||||
|
||||
internal bool HasActivePlacement(uint serverGuid) =>
|
||||
_activePlacementGenerationByGuid.ContainsKey(serverGuid);
|
||||
|
||||
public void Forget(LiveEntityRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
if (_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort generation)
|
||||
&& generation == record.Generation)
|
||||
{
|
||||
_readyGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
if (_suspendedShadowGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out ushort suspendedGeneration)
|
||||
&& suspendedGeneration == record.Generation)
|
||||
{
|
||||
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
if (_activePlacementGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out ushort activeGeneration)
|
||||
&& activeGeneration == record.Generation)
|
||||
{
|
||||
_activePlacementGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_readyGenerationByGuid.Clear();
|
||||
_suspendedShadowGenerationByGuid.Clear();
|
||||
_activePlacementGenerationByGuid.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
|
||||
Clear();
|
||||
}
|
||||
|
||||
private void ApplyPendingTransitions(LiveEntityRecord record)
|
||||
{
|
||||
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
|
||||
{
|
||||
if (record.WorldEntity is not { } entity)
|
||||
return;
|
||||
|
||||
// set_state writes the final state before any PartArray/cell side
|
||||
// effect. Retained shadow registrations must see those same bits
|
||||
// even while their cell rows are suspended.
|
||||
_shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
|
||||
|
||||
switch (transition.HiddenTransition)
|
||||
{
|
||||
case RetailHiddenTransition.BecameHidden:
|
||||
_playTyped(entity.Id, HiddenScriptType, 1f);
|
||||
_setDirectChildrenNoDraw(record.ServerGuid, true);
|
||||
_shadows.Suspend(entity.Id);
|
||||
if (!IsPlacementActive(record))
|
||||
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
|
||||
_clearInvalidTarget(record.ServerGuid);
|
||||
break;
|
||||
|
||||
case RetailHiddenTransition.BecameVisible:
|
||||
_playTyped(entity.Id, UnHideScriptType, 1f);
|
||||
_setDirectChildrenNoDraw(record.ServerGuid, false);
|
||||
if (!IsPlacementActive(record) && RestoreShadow(record, entity))
|
||||
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool RestoreShadow(LiveEntityRecord record, AcDream.Core.World.WorldEntity entity)
|
||||
{
|
||||
if (!record.IsSpatiallyProjected
|
||||
|| !record.IsSpatiallyVisible
|
||||
|| record.FullCellId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
(int centerX, int centerY) = _liveCenter();
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
record.FullCellId,
|
||||
centerX,
|
||||
centerY);
|
||||
_onShadowRestored?.Invoke(record.ServerGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
if (!visible
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|
||||
|| IsPlacementActive(record)
|
||||
|| !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration)
|
||||
|| readyGeneration != record.Generation
|
||||
|| !_suspendedShadowGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out ushort suspendedGeneration)
|
||||
|| suspendedGeneration != record.Generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (RestoreShadow(record, entity))
|
||||
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
|
||||
private bool IsPlacementActive(LiveEntityRecord record) =>
|
||||
_activePlacementGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out ushort generation)
|
||||
&& generation == record.Generation;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using AcDream.Core.Net;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
|
|
@ -32,6 +33,24 @@ public interface ILiveEntityCanonicalCellConsumer
|
|||
void BindCanonicalCell(Func<uint> read, Action<uint> write);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remote CPhysicsObj state required to finish a retail MoveOrTeleport
|
||||
/// placement. A same-incarnation runtime replacement may wrap the same body,
|
||||
/// but it must preserve this complete placement contract.
|
||||
/// </summary>
|
||||
public interface ILiveEntityRemotePlacementRuntime :
|
||||
ILiveEntityRemoteMotionRuntime,
|
||||
ILiveEntityCanonicalCellConsumer
|
||||
{
|
||||
uint CellId { get; set; }
|
||||
bool Airborne { get; set; }
|
||||
Vector3 LastServerPosition { get; set; }
|
||||
double LastServerPositionTime { get; set; }
|
||||
Vector3 LastShadowSyncPosition { get; set; }
|
||||
void HitGround();
|
||||
void LeaveGround();
|
||||
}
|
||||
|
||||
/// <summary>Projectile physics state owned by a live object.</summary>
|
||||
public interface ILiveEntityProjectileRuntime
|
||||
{
|
||||
|
|
@ -83,11 +102,15 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
|
|||
/// </summary>
|
||||
public sealed class LiveEntityRecord
|
||||
{
|
||||
private readonly Queue<RetailPhysicsStateTransition> _pendingStateTransitions = new();
|
||||
|
||||
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
|
||||
{
|
||||
ServerGuid = snapshot.Guid;
|
||||
Snapshot = snapshot;
|
||||
RefreshDerivedState();
|
||||
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
|
||||
ApplyRawPhysicsState(RawPhysicsState);
|
||||
}
|
||||
|
||||
public uint ServerGuid { get; }
|
||||
|
|
@ -102,6 +125,7 @@ public sealed class LiveEntityRecord
|
|||
public PhysicsBody? PhysicsBody { get; internal set; }
|
||||
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
|
||||
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
|
||||
internal bool RequiresRemotePlacementRuntime { get; set; }
|
||||
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
|
||||
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
|
||||
public bool ResourcesRegistered { get; internal set; }
|
||||
|
|
@ -110,6 +134,31 @@ public sealed class LiveEntityRecord
|
|||
public bool WorldSpawnPublished { get; internal set; }
|
||||
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
||||
|
||||
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
|
||||
_pendingStateTransitions.TryDequeue(out transition);
|
||||
|
||||
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
|
||||
{
|
||||
RawPhysicsState = rawState;
|
||||
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
|
||||
FinalPhysicsState,
|
||||
(PhysicsStateFlags)rawState);
|
||||
FinalPhysicsState = transition.FinalState;
|
||||
if (PhysicsBody is not null)
|
||||
PhysicsBody.State = FinalPhysicsState;
|
||||
_pendingStateTransitions.Enqueue(transition);
|
||||
return transition;
|
||||
}
|
||||
|
||||
internal void SetChildNoDraw(bool noDraw)
|
||||
{
|
||||
FinalPhysicsState = noDraw
|
||||
? FinalPhysicsState | PhysicsStateFlags.NoDraw
|
||||
: FinalPhysicsState & ~PhysicsStateFlags.NoDraw;
|
||||
if (PhysicsBody is not null)
|
||||
PhysicsBody.State = FinalPhysicsState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cell used when a streamed landblock asks live objects to restore their
|
||||
/// spatial projection. Once materialized, the canonical runtime cell wins;
|
||||
|
|
@ -129,7 +178,6 @@ public sealed class LiveEntityRecord
|
|||
RawPhysicsState = Snapshot.Physics?.RawState
|
||||
?? Snapshot.PhysicsState
|
||||
?? 0u;
|
||||
FinalPhysicsState = (PhysicsStateFlags)RawPhysicsState;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,8 +253,9 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
/// <summary>
|
||||
/// Currently visible top-level world projections. Attached children and
|
||||
/// pending projections are excluded from radar, picking, status, and target
|
||||
/// candidate consumers.
|
||||
/// pending or Hidden projections are excluded from radar, picking, status,
|
||||
/// and target candidate consumers. NoDraw alone suppresses the mesh but is
|
||||
/// not a retail cell-visibility transition.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
|
||||
_visibleWorldEntitiesByGuid;
|
||||
|
|
@ -330,6 +379,15 @@ public sealed class LiveEntityRuntime
|
|||
}
|
||||
|
||||
record.ProjectionKind = projectionKind;
|
||||
if (projectionKind is LiveEntityProjectionKind.World)
|
||||
{
|
||||
// Attached projections inherit the complete ancestor visibility
|
||||
// chain. Once the same logical entity becomes a top-level world
|
||||
// projection it has no render ancestor, so retained inherited
|
||||
// suppression must be cleared at this ownership transition.
|
||||
record.WorldEntity!.IsAncestorDrawVisible = true;
|
||||
}
|
||||
RefreshPresentation(record);
|
||||
RebucketLiveEntity(serverGuid, fullCellId);
|
||||
return record.WorldEntity;
|
||||
}
|
||||
|
|
@ -362,10 +420,7 @@ public sealed class LiveEntityRuntime
|
|||
_materializedWorldEntitiesByGuid[serverGuid] = entity;
|
||||
else
|
||||
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
||||
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
|
||||
_visibleWorldEntitiesByGuid[serverGuid] = entity;
|
||||
else
|
||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
||||
RefreshPresentation(record);
|
||||
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
|
||||
record.FullCellId = spatialCellOrLandblockId;
|
||||
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
|
||||
|
|
@ -387,13 +442,38 @@ public sealed class LiveEntityRuntime
|
|||
return false;
|
||||
|
||||
_spatial.RemoveLiveEntityProjection(serverGuid);
|
||||
// Usually GpuWorldState delivers the false edge synchronously above.
|
||||
// During a reentrant visibility callback it queues that edge until the
|
||||
// outer notification completes, so publish the logical withdrawal now;
|
||||
// OnSpatialVisibilityChanged rejects the later duplicate against this
|
||||
// committed record state.
|
||||
bool spatialEdgeWasDeferred = record.IsSpatiallyVisible;
|
||||
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
||||
record.IsSpatiallyProjected = false;
|
||||
record.IsSpatiallyVisible = false;
|
||||
if (spatialEdgeWasDeferred)
|
||||
ProjectionVisibilityChanged?.Invoke(record, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws a still-live projection whose authoritative rollback frame
|
||||
/// is outside every world cell. Logical owners remain registered.
|
||||
/// </summary>
|
||||
internal bool WithdrawLiveEntityProjectionToCellless(uint serverGuid)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
|| record.WorldEntity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ClearWorldCell(serverGuid);
|
||||
record.WorldEntity.ParentCellId = 0u;
|
||||
return WithdrawLiveEntityProjection(serverGuid);
|
||||
}
|
||||
|
||||
public bool UnregisterLiveEntity(
|
||||
DeleteObject.Parsed delete,
|
||||
bool isLocalPlayer,
|
||||
|
|
@ -449,6 +529,16 @@ public sealed class LiveEntityRuntime
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a top-level object that currently participates in picking,
|
||||
/// targeting, radar, and wire-driven MoveTo/Sticky establishment.
|
||||
/// Pending, attached, and Hidden projections are intentionally excluded.
|
||||
/// </summary>
|
||||
public bool TryGetInteractionEligibleEntity(
|
||||
uint serverGuid,
|
||||
out WorldEntity entity) =>
|
||||
_visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!);
|
||||
|
||||
public bool ContainsWorldEntity(uint serverGuid) =>
|
||||
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
&& record.WorldEntity is not null;
|
||||
|
|
@ -503,14 +593,24 @@ public sealed class LiveEntityRuntime
|
|||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
||||
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
|
||||
if (record.ProjectileRuntime is { } projectile
|
||||
&& !ReferenceEquals(projectile.Body, runtime.Body))
|
||||
PhysicsBody candidateBody = runtime.Body;
|
||||
if (record.PhysicsBody is { } canonicalBody
|
||||
&& !ReferenceEquals(canonicalBody, candidateBody))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
|
||||
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
|
||||
}
|
||||
if (record.RequiresRemotePlacementRuntime
|
||||
&& runtime is not ILiveEntityRemotePlacementRuntime)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation.");
|
||||
}
|
||||
record.RequiresRemotePlacementRuntime |=
|
||||
runtime is ILiveEntityRemotePlacementRuntime;
|
||||
record.RemoteMotionRuntime = runtime;
|
||||
record.PhysicsBody = runtime.Body;
|
||||
record.PhysicsBody = candidateBody;
|
||||
candidateBody.State = record.FinalPhysicsState;
|
||||
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
|
||||
{
|
||||
// Capture the incarnation, not only its GUID. A callback retained
|
||||
|
|
@ -548,8 +648,6 @@ public sealed class LiveEntityRuntime
|
|||
return false;
|
||||
_remoteMotionByGuid.Remove(serverGuid);
|
||||
record.RemoteMotionRuntime = null;
|
||||
if (record.ProjectileRuntime is null)
|
||||
record.PhysicsBody = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -562,21 +660,17 @@ public sealed class LiveEntityRuntime
|
|||
throw new InvalidOperationException(
|
||||
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
|
||||
}
|
||||
if (record.RemoteMotionRuntime is { } remote
|
||||
&& !ReferenceEquals(remote.Body, runtime.Body))
|
||||
PhysicsBody candidateBody = runtime.Body;
|
||||
if (record.PhysicsBody is { } canonicalBody
|
||||
&& !ReferenceEquals(canonicalBody, candidateBody))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} already owns a different remote-motion physics body.");
|
||||
}
|
||||
if (record.ProjectileRuntime is { } projectile
|
||||
&& !ReferenceEquals(projectile.Body, runtime.Body))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
|
||||
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
|
||||
}
|
||||
|
||||
record.ProjectileRuntime = runtime;
|
||||
record.PhysicsBody = runtime.Body;
|
||||
record.PhysicsBody = candidateBody;
|
||||
candidateBody.State = record.FinalPhysicsState;
|
||||
}
|
||||
|
||||
public bool TryGetProjectileRuntime(
|
||||
|
|
@ -601,8 +695,6 @@ public sealed class LiveEntityRuntime
|
|||
return false;
|
||||
|
||||
record.ProjectileRuntime = null;
|
||||
if (record.RemoteMotionRuntime is null)
|
||||
record.PhysicsBody = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -694,12 +786,40 @@ public sealed class LiveEntityRuntime
|
|||
}
|
||||
|
||||
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
=> TryApplyState(update, out accepted, out _);
|
||||
|
||||
public bool TryApplyState(
|
||||
SetState.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out RetailPhysicsStateTransition transition)
|
||||
{
|
||||
bool applied = _inbound.TryApplyState(update, out accepted);
|
||||
if (applied) RefreshRecord(update.Guid, accepted);
|
||||
transition = default;
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.Guid, accepted);
|
||||
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
||||
{
|
||||
transition = record.ApplyRawPhysicsState(update.PhysicsState);
|
||||
RefreshPresentation(record);
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail parent Hidden directly toggles each attached child's NoDraw bit
|
||||
/// without consuming the child's STATE_TS or replacing its raw wire state.
|
||||
/// </summary>
|
||||
public bool SetAttachedChildNoDraw(uint childServerGuid, bool noDraw)
|
||||
{
|
||||
if (!_recordsByGuid.TryGetValue(childServerGuid, out LiveEntityRecord? record))
|
||||
return false;
|
||||
record.SetChildNoDraw(noDraw);
|
||||
RefreshPresentation(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyPosition(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
bool isLocalPlayer,
|
||||
|
|
@ -709,6 +829,12 @@ public sealed class LiveEntityRuntime
|
|||
out WorldSession.EntitySpawn accepted,
|
||||
out AcceptedPhysicsTimestamps timestamps)
|
||||
{
|
||||
bool wasCellLess = _recordsByGuid.TryGetValue(
|
||||
update.Guid,
|
||||
out LiveEntityRecord? beforePosition)
|
||||
&& (beforePosition.FullCellId == 0
|
||||
|| !beforePosition.IsSpatiallyProjected
|
||||
|| !beforePosition.IsSpatiallyVisible);
|
||||
bool known = _inbound.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer,
|
||||
|
|
@ -720,6 +846,13 @@ public sealed class LiveEntityRuntime
|
|||
if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
||||
{
|
||||
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
|
||||
if (disposition is PositionTimestampDisposition.Apply)
|
||||
{
|
||||
timestamps = timestamps with
|
||||
{
|
||||
TeleportHookRequired = timestamps.TeleportAdvanced || wasCellLess,
|
||||
};
|
||||
}
|
||||
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
|
||||
if (acceptedPosition)
|
||||
{
|
||||
|
|
@ -734,8 +867,11 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
|
||||
/// movement/animation only while the object has visible world-cell
|
||||
/// membership and is not Frozen. A pickup or parent transition keeps
|
||||
/// object tick only while the object has visible world-cell membership and
|
||||
/// is not Frozen. Hidden is deliberately not a broad tick stop: retail
|
||||
/// skips PartArray animation and physics, but still applies the
|
||||
/// PositionManager offset and advances targeting/movement/script/particle
|
||||
/// owners. A pickup or parent transition keeps
|
||||
/// script/effect owners but clears the cell and withdraws the root
|
||||
/// projection until a fresh Position re-enters it.
|
||||
/// </summary>
|
||||
|
|
@ -746,6 +882,10 @@ public sealed class LiveEntityRuntime
|
|||
&& record.FullCellId != 0
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
|
||||
|
||||
public bool IsHidden(uint serverGuid) =>
|
||||
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Claims the one logical top-level spawn notification for this object
|
||||
/// incarnation. Spatial re-entry must restore presentation without replaying
|
||||
|
|
@ -832,15 +972,31 @@ public sealed class LiveEntityRuntime
|
|||
|| record.WorldEntity is not { } entity)
|
||||
return;
|
||||
|
||||
bool wasVisible = record.IsSpatiallyVisible;
|
||||
record.IsSpatiallyVisible = visible;
|
||||
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
|
||||
_visibleWorldEntitiesByGuid[serverGuid] = entity;
|
||||
else
|
||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
||||
if (_rebucketingGuid != serverGuid)
|
||||
RefreshPresentation(record);
|
||||
if (_rebucketingGuid != serverGuid && wasVisible != visible)
|
||||
ProjectionVisibilityChanged?.Invoke(record, visible);
|
||||
}
|
||||
|
||||
private void RefreshPresentation(LiveEntityRecord record)
|
||||
{
|
||||
if (record.WorldEntity is not { } entity)
|
||||
return;
|
||||
|
||||
PhysicsStateFlags state = record.FinalPhysicsState;
|
||||
entity.IsDrawVisible =
|
||||
(state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0;
|
||||
|
||||
bool interactionVisible = record.IsSpatiallyVisible
|
||||
&& record.ProjectionKind is LiveEntityProjectionKind.World
|
||||
&& (state & PhysicsStateFlags.Hidden) == 0;
|
||||
if (interactionVisible)
|
||||
_visibleWorldEntitiesByGuid[record.ServerGuid] = entity;
|
||||
else
|
||||
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
|
||||
private void TearDownRecord(LiveEntityRecord record)
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
|
|
@ -873,6 +1029,7 @@ public sealed class LiveEntityRuntime
|
|||
_remoteMotionByGuid.Remove(record.ServerGuid);
|
||||
record.AnimationRuntime = null;
|
||||
record.RemoteMotionRuntime = null;
|
||||
record.RequiresRemotePlacementRuntime = false;
|
||||
record.PhysicsBody = null;
|
||||
record.ProjectileRuntime = null;
|
||||
record.EffectProfile = null;
|
||||
|
|
|
|||
33
src/AcDream.App/World/LiveEntityTeardown.cs
Normal file
33
src/AcDream.App/World/LiveEntityTeardown.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the independent owners that participate in one live incarnation's
|
||||
/// teardown without letting an earlier callback strand later state. Retail's
|
||||
/// object destruction drains these owners as separate lifecycle steps; the App
|
||||
/// composition callback must preserve that symmetry even when a plugin,
|
||||
/// renderer, or effect sink throws.
|
||||
/// </summary>
|
||||
internal static class LiveEntityTeardown
|
||||
{
|
||||
internal static void Run(IEnumerable<Action> cleanups)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cleanups);
|
||||
List<Exception>? failures = null;
|
||||
foreach (Action cleanup in cleanups)
|
||||
{
|
||||
try
|
||||
{
|
||||
cleanup();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
"One or more live-entity component teardown steps failed.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue