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

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