feat(vfx): port retail hidden and teleport presentation

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

View file

@ -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;

View file

@ -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;

View file

@ -11,6 +11,8 @@ namespace AcDream.App.Physics;
/// frame, from inside the same guard the body used to live under
/// (<c>ae.Sequencer != null &amp;&amp; serverGuid != 0 &amp;&amp; serverGuid != _playerServerGuid
/// &amp;&amp; rm.LastServerPosTime &gt; 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;
}
}

View 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;
}

View 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);

View 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);
}

View file

@ -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

View file

@ -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(

View file

@ -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();

View file

@ -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;
}

View file

@ -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;

View file

@ -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();

View file

@ -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());

View file

@ -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,

View 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;
}

View file

@ -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;

View 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);
}
}