feat(vfx): port retail hidden and teleport presentation
Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
This commit is contained in:
parent
a51ebc66e9
commit
1e98d81448
46 changed files with 4883 additions and 127 deletions
|
|
@ -135,6 +135,19 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
|
|||
/// registry.</summary>
|
||||
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>
|
||||
/// A Hidden object remains logically alive but is removed from the cell
|
||||
/// lookup/interaction surface. Retail's DetectionManager sends
|
||||
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
|
||||
/// not ported yet, so the App bridges the same unavailability edge through
|
||||
/// the existing target fan-out. The non-Ok update tears down watcher
|
||||
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
|
||||
/// UnHide re-subscription receives a fresh initial snapshot. The object's
|
||||
/// own watcher role is deliberately preserved.
|
||||
/// </summary>
|
||||
public void NotifyHidden() =>
|
||||
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
|
||||
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
|
||||
/// (drop this entity's OWN subscription) then
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability);
|
||||
if (!ApplyParentWorldPose(child.Entity, parentWorld))
|
||||
return false;
|
||||
ApplyParentDrawVisibility(child.Entity, parent);
|
||||
child.Entity.ParentCellId = parent.ParentCellId;
|
||||
PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
|
||||
if (parent.ParentCellId is { } parentCellId)
|
||||
|
|
@ -317,6 +318,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
if (entity is null)
|
||||
return false;
|
||||
ApplyParentWorldPose(entity, parentWorld);
|
||||
ApplyParentDrawVisibility(entity, parentEntity);
|
||||
entity.ParentCellId = parentCellId;
|
||||
entity.ApplyAppearance(
|
||||
pose.AttachedParts,
|
||||
|
|
@ -338,6 +340,14 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
pose.AttachedParts,
|
||||
scale,
|
||||
entity);
|
||||
if (_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord)
|
||||
&& (parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
|
||||
{
|
||||
// A child attached while its parent is already Hidden inherits
|
||||
// the same direct child NoDraw mutation retail applies from
|
||||
// CPhysicsObj::set_hidden (0x00514C60).
|
||||
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
|
||||
}
|
||||
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
|
||||
Console.WriteLine(
|
||||
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
||||
|
|
@ -376,6 +386,18 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preserve retail attachment-tree draw inheritance after adapting every
|
||||
/// child CPhysicsObj into an independent retained draw entry. This does not
|
||||
/// alter the child's own NoDraw state; parent-first ticking propagates an
|
||||
/// ancestor's suppression through arbitrarily deep attachment chains.
|
||||
/// </summary>
|
||||
internal static void ApplyParentDrawVisibility(WorldEntity child, WorldEntity parent)
|
||||
{
|
||||
child.IsAncestorDrawVisible =
|
||||
parent.IsDrawVisible && parent.IsAncestorDrawVisible;
|
||||
}
|
||||
|
||||
private static bool TryDecomposeWorldPose(
|
||||
Matrix4x4 world,
|
||||
out Vector3 position,
|
||||
|
|
@ -425,6 +447,20 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::set_hidden</c> walks the direct CHILDLIST and
|
||||
/// sets/clears each child's NoDraw bit before changing root collision/cell
|
||||
/// presentation. Descendants are not recursively rewritten here.
|
||||
/// </summary>
|
||||
public void SetDirectChildrenNoDraw(uint parentGuid, bool noDraw)
|
||||
{
|
||||
foreach (AttachedChild child in _attachedByChild.Values)
|
||||
{
|
||||
if (child.ParentGuid == parentGuid)
|
||||
_liveEntities.SetAttachedChildNoDraw(child.ChildGuid, noDraw);
|
||||
}
|
||||
}
|
||||
|
||||
private void ResolveRelations(uint childGuid)
|
||||
{
|
||||
Relations.Resolve(
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ public sealed class GameWindow : IDisposable
|
|||
// once the injected shared helpers exist as method groups (GetSetupCylinder,
|
||||
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
|
||||
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
|
||||
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
||||
// Step 7 projectile presentation. The controller owns no identity map;
|
||||
// each runtime component is stored on the canonical LiveEntityRecord.
|
||||
private AcDream.App.Physics.ProjectileController? _projectileController;
|
||||
|
|
@ -460,10 +461,9 @@ public sealed class GameWindow : IDisposable
|
|||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class RemoteMotion :
|
||||
AcDream.App.World.ILiveEntityRemoteMotionRuntime,
|
||||
AcDream.App.World.ILiveEntityCanonicalCellConsumer // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
||||
AcDream.App.World.ILiveEntityRemotePlacementRuntime // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
||||
{
|
||||
public AcDream.Core.Physics.PhysicsBody Body;
|
||||
public AcDream.Core.Physics.PhysicsBody Body { get; }
|
||||
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
|
||||
|
||||
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
|
||||
|
|
@ -613,6 +613,36 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition
|
||||
{
|
||||
get => LastServerPos;
|
||||
set => LastServerPos = value;
|
||||
}
|
||||
|
||||
double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime
|
||||
{
|
||||
get => LastServerPosTime;
|
||||
set => LastServerPosTime = value;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition
|
||||
{
|
||||
get => LastShadowSyncPos;
|
||||
set => LastShadowSyncPos = value;
|
||||
}
|
||||
|
||||
bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne
|
||||
{
|
||||
get => Airborne;
|
||||
set => Airborne = value;
|
||||
}
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() =>
|
||||
Movement.HitGround();
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() =>
|
||||
Motion.LeaveGround();
|
||||
|
||||
/// <summary>
|
||||
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
|
||||
/// arc in flight). Set when a 0xF74E VectorUpdate arrives with
|
||||
|
|
@ -857,6 +887,7 @@ public sealed class GameWindow : IDisposable
|
|||
// from the animation pipeline flip the matching LightSource.IsLit.
|
||||
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
||||
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
|
||||
private AcDream.App.World.LiveEntityPresentationController? _liveEntityPresentation;
|
||||
|
||||
// #188 — TransparentPartHook fires from the animation pipeline drive
|
||||
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
|
||||
|
|
@ -2173,7 +2204,8 @@ public sealed class GameWindow : IDisposable
|
|||
playerCellId: () => _playerController?.CellId ?? 0u,
|
||||
selectedGuid: () => _selection.SelectedObjectId,
|
||||
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
|
||||
uiLocked: () => _persistedGameplay.LockUI);
|
||||
uiLocked: () => _persistedGameplay.LockUI,
|
||||
playerEntities: _entitiesByServerGuid);
|
||||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||||
_retailChatVm = retailChatVm;
|
||||
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
|
||||
|
|
@ -2444,7 +2476,6 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
|
||||
};
|
||||
|
||||
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
|
||||
_liveEntities,
|
||||
_effectPoses,
|
||||
|
|
@ -2479,9 +2510,34 @@ public sealed class GameWindow : IDisposable
|
|||
_entityEffects = entityEffects;
|
||||
entityEffects.DiagnosticSink = message =>
|
||||
Console.Error.WriteLine($"vfx: {message}");
|
||||
_liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController(
|
||||
_liveEntities,
|
||||
_physicsEngine.ShadowObjects,
|
||||
entityEffects.PlayTyped,
|
||||
_equippedChildRenderer.SetDirectChildrenNoDraw,
|
||||
ClearTargetForHiddenEntity,
|
||||
() => (_liveCenterX, _liveCenterY));
|
||||
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
|
||||
_physicsEngine,
|
||||
_liveEntities,
|
||||
GetSetupCylinder,
|
||||
CellLocalForSeed,
|
||||
(entity, remote, cellId) => _remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||
entity.Id,
|
||||
remote,
|
||||
_liveCenterX,
|
||||
_liveCenterY,
|
||||
cellId),
|
||||
(guid, generation, deferShadowRestore) =>
|
||||
_liveEntityPresentation.CompleteAuthoritativePlacement(
|
||||
guid,
|
||||
generation,
|
||||
deferShadowRestore),
|
||||
(guid, generation) =>
|
||||
_liveEntityPresentation.BeginAuthoritativePlacement(guid, generation));
|
||||
_equippedChildRenderer.EntityReady += guid =>
|
||||
{
|
||||
entityEffects.OnLiveEntityReady(guid);
|
||||
CompleteLiveEntityReady(guid);
|
||||
};
|
||||
_equippedChildRenderer.ProjectionPoseReady += guid =>
|
||||
_liveEntityLights.OnAttachedPoseReady(guid);
|
||||
|
|
@ -2704,16 +2760,33 @@ public sealed class GameWindow : IDisposable
|
|||
_equippedChildRenderer?.Clear();
|
||||
Objects.Clear();
|
||||
_selection.Reset();
|
||||
_pendingPostArrivalAction = null;
|
||||
try
|
||||
{
|
||||
_liveEntities?.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// F754/F755 can precede CreateObject, so the mixed pending FIFO
|
||||
// may contain owners with no LiveEntityRecord to tear down.
|
||||
_entityEffects?.ClearNetworkState();
|
||||
_animationHookFrames?.Clear();
|
||||
try
|
||||
{
|
||||
// A pending teleport is operational state outside the live
|
||||
// record. Clear it independently even if a resource callback
|
||||
// failed while the canonical runtime was draining.
|
||||
_remoteTeleportController?.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
// F754/F755 can precede CreateObject, so the mixed pending FIFO
|
||||
// may contain owners with no LiveEntityRecord to tear down.
|
||||
_entityEffects?.ClearNetworkState();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_animationHookFrames?.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4096,7 +4169,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Renderer, script owner, optional animation owner, collision body,
|
||||
// and effect profile are now all installed. Only at this boundary may
|
||||
// the mixed pre-Create F754/F755 FIFO replay against the local ID.
|
||||
_entityEffects?.OnLiveEntityReady(spawn.Guid);
|
||||
CompleteLiveEntityReady(spawn.Guid);
|
||||
|
||||
// Dump a summary periodically so we can see drop breakdowns without
|
||||
// waiting for a graceful shutdown.
|
||||
|
|
@ -4468,7 +4541,9 @@ public sealed class GameWindow : IDisposable
|
|||
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
|
||||
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
|
||||
|
||||
uint state = spawn.PhysicsState ?? 0u;
|
||||
uint state = _liveEntities?.TryGetRecord(spawn.Guid, out LiveEntityRecord liveRecord) == true
|
||||
? (uint)liveRecord.FinalPhysicsState
|
||||
: spawn.PhysicsState ?? 0u;
|
||||
|
||||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||||
entityId: entity.Id,
|
||||
|
|
@ -4596,6 +4671,13 @@ public sealed class GameWindow : IDisposable
|
|||
uint cellId,
|
||||
bool force = false)
|
||||
{
|
||||
if (_liveEntities?.IsHidden(_playerServerGuid) == true)
|
||||
{
|
||||
_physicsEngine.ShadowObjects.Suspend(playerEntity.Id);
|
||||
_lastLocalPlayerShadow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force
|
||||
&& _lastLocalPlayerShadow is { } last
|
||||
&& last.CellId == cellId
|
||||
|
|
@ -4836,17 +4918,20 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
|
||||
{
|
||||
// CObjCell::hide_object removes Hidden objects from the lookup surface
|
||||
// used to establish new target/voyeur relationships. Existing
|
||||
// subscriptions are cleared by LiveEntityPresentationController.
|
||||
if (!_visibleEntitiesByServerGuid.ContainsKey(id))
|
||||
return null;
|
||||
if (_physicsHosts.TryGetValue(id, out var existing))
|
||||
return existing;
|
||||
if (!_entitiesByServerGuid.ContainsKey(id))
|
||||
return null;
|
||||
|
||||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
var minimal = new EntityPhysicsHost(
|
||||
id,
|
||||
getPosition: () => new AcDream.Core.Physics.Position(
|
||||
0u,
|
||||
_entitiesByServerGuid.TryGetValue(id, out var e)
|
||||
_visibleEntitiesByServerGuid.TryGetValue(id, out var e)
|
||||
? e.Position : System.Numerics.Vector3.Zero,
|
||||
System.Numerics.Quaternion.Identity),
|
||||
getVelocity: () => System.Numerics.Vector3.Zero, // static target
|
||||
|
|
@ -4914,20 +4999,63 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (host is null)
|
||||
return;
|
||||
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var tgtEnt))
|
||||
if (_liveEntities is not { } liveEntities
|
||||
|| !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt))
|
||||
return;
|
||||
var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
|
||||
host.PositionManager.StickTo(targetGuid, radius, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes retail object construction in order: register the effect
|
||||
/// owner, apply constructor/PhysicsDesc state transitions, then replay
|
||||
/// SmartBox's queued F754/F755 blobs.
|
||||
/// </summary>
|
||||
private void CompleteLiveEntityReady(uint serverGuid)
|
||||
{
|
||||
if (_entityEffects?.PrepareLiveEntityOwner(serverGuid) != true)
|
||||
return;
|
||||
_liveEntityPresentation?.OnLiveEntityReady(serverGuid);
|
||||
_entityEffects.ReplayPendingForLiveEntity(serverGuid);
|
||||
}
|
||||
|
||||
private void ClearTargetForHiddenEntity(uint serverGuid)
|
||||
{
|
||||
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
||||
&& pendingGuid == serverGuid)
|
||||
_pendingPostArrivalAction = null;
|
||||
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||
AcDream.Core.Selection.SelectionChangeReason.Cleared);
|
||||
}
|
||||
|
||||
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost)
|
||||
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
|
||||
hiddenEntityHost.NotifyHidden();
|
||||
}
|
||||
|
||||
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
|
||||
{
|
||||
_entityEffects?.OnLiveEntityUnregistered(record);
|
||||
uint serverGuid = record.ServerGuid;
|
||||
var cleanups = new List<Action>
|
||||
{
|
||||
() => _liveEntityPresentation?.Forget(record),
|
||||
() => _entityEffects?.OnLiveEntityUnregistered(record),
|
||||
() => _remoteTeleportController?.Forget(serverGuid),
|
||||
};
|
||||
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
||||
&& pendingGuid == serverGuid)
|
||||
_pendingPostArrivalAction = null;
|
||||
|
||||
if (record.WorldEntity is not { } existingEntity)
|
||||
{
|
||||
AcDream.App.World.LiveEntityTeardown.Run(cleanups);
|
||||
return;
|
||||
}
|
||||
|
||||
uint serverGuid = record.ServerGuid;
|
||||
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
|
||||
// independently (r3-port-plan §4): the manager's (each pending
|
||||
// animation fires MotionDone(success:false) → the bound interp pops
|
||||
|
|
@ -4935,9 +5063,9 @@ public sealed class GameWindow : IDisposable
|
|||
// MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade
|
||||
// relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30).
|
||||
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
|
||||
aeGone.Sequencer?.Manager.HandleExitWorld();
|
||||
cleanups.Add(() => aeGone.Sequencer?.Manager.HandleExitWorld());
|
||||
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
|
||||
rmGone.Movement.HandleExitWorld();
|
||||
cleanups.Add(rmGone.Movement.HandleExitWorld);
|
||||
// R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this
|
||||
// entity that it left the world (TargetManager::NotifyVoyeurOfEvent
|
||||
// ExitWorld) — a watcher moving-to/stuck-to this entity drops the
|
||||
|
|
@ -4956,31 +5084,35 @@ public sealed class GameWindow : IDisposable
|
|||
// (ExitWorld) tells the entities watching IT. Without the first
|
||||
// two, a despawning attacker leaves a dead voyeur entry on its
|
||||
// target until send-failure pruning.
|
||||
ephGone.PositionManager.UnStick();
|
||||
ephGone.ClearTarget();
|
||||
ephGone.NotifyExitWorld();
|
||||
cleanups.Add(ephGone.PositionManager.UnStick);
|
||||
cleanups.Add(ephGone.ClearTarget);
|
||||
cleanups.Add(ephGone.NotifyExitWorld);
|
||||
}
|
||||
_physicsHosts.Remove(serverGuid);
|
||||
_animatedEntities.Remove(existingEntity.Id);
|
||||
_classificationCache.InvalidateEntity(existingEntity.Id);
|
||||
cleanups.Add(() => _physicsHosts.Remove(serverGuid));
|
||||
cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id));
|
||||
cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id));
|
||||
|
||||
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
||||
// clear using the same guid the next spawn/update would use.
|
||||
_remoteDeadReckon.Remove(serverGuid);
|
||||
_remoteLastMove.Remove(serverGuid);
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
cleanups.Add(() => _remoteDeadReckon.Remove(serverGuid));
|
||||
cleanups.Add(() => _remoteLastMove.Remove(serverGuid));
|
||||
cleanups.Add(() =>
|
||||
{
|
||||
_selection.Clear(
|
||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
});
|
||||
|
||||
_translucencyFades.ClearEntity(existingEntity.Id); // #188
|
||||
LeaveWorldLiveEntityRuntimeComponents(record);
|
||||
cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188
|
||||
cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record));
|
||||
// Logical teardown ends the retained shadow payload too. The weaker
|
||||
// pickup/parent path stops after Suspend so re-entry can restore it.
|
||||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
||||
_liveEntityLights?.Forget(existingEntity.Id);
|
||||
cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id));
|
||||
cleanups.Add(() => _liveEntityLights?.Forget(existingEntity.Id));
|
||||
AcDream.App.World.LiveEntityTeardown.Run(cleanups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -5075,7 +5207,8 @@ public sealed class GameWindow : IDisposable
|
|||
// MoveToPosition at the wire origin (§2f).
|
||||
if (update.MotionState.MovementType == 6
|
||||
&& path.TargetGuid is { } tgtGuid
|
||||
&& _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
|
||||
&& _liveEntities is { } liveMoveEntities
|
||||
&& liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt))
|
||||
{
|
||||
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
|
||||
ms.ObjectId = tgtGuid;
|
||||
|
|
@ -5115,7 +5248,8 @@ public sealed class GameWindow : IDisposable
|
|||
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
|
||||
if (update.MotionState.MovementType == 8
|
||||
&& turnPath.TargetGuid is { } turnTgt
|
||||
&& _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
|
||||
&& _liveEntities is { } liveTurnEntities
|
||||
&& liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt))
|
||||
{
|
||||
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
|
||||
ms.ObjectId = turnTgt;
|
||||
|
|
@ -5761,15 +5895,25 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
|
||||
{
|
||||
if (!_liveEntities!.TryApplyState(parsed, out _)) return;
|
||||
if (!_liveEntities!.TryApplyState(parsed, out _, out _)) return;
|
||||
|
||||
if (!_liveEntities.TryGetRecord(parsed.Guid, out LiveEntityRecord record))
|
||||
return;
|
||||
|
||||
// Retail set_state order: Lighting, NoDraw, then Hidden. The live
|
||||
// runtime already committed the raw/final bits and draw visibility;
|
||||
// apply the ordered owners before updating motion/collision consumers.
|
||||
_liveEntityLights?.OnStateChanged(parsed.Guid);
|
||||
_liveEntityPresentation?.OnStateAccepted(parsed.Guid);
|
||||
|
||||
_projectileController?.ApplyAuthoritativeState(
|
||||
parsed.Guid,
|
||||
(AcDream.Core.Physics.PhysicsStateFlags)parsed.PhysicsState,
|
||||
record.FinalPhysicsState,
|
||||
_physicsScriptGameTime,
|
||||
_liveCenterX,
|
||||
_liveCenterY);
|
||||
_liveEntityLights?.OnStateChanged(parsed.Guid);
|
||||
if (parsed.Guid == _playerServerGuid)
|
||||
_playerController?.ApplyPhysicsState(record.FinalPhysicsState);
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
|
||||
|
||||
|
|
@ -5782,11 +5926,9 @@ public sealed class GameWindow : IDisposable
|
|||
// ACE flipped the ETHEREAL bit.
|
||||
uint registryKey = entity.Id;
|
||||
|
||||
_physicsEngine.ShadowObjects.UpdatePhysicsState(registryKey, parsed.PhysicsState);
|
||||
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
|
||||
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} raw=0x{parsed.PhysicsState:X8} final=0x{(uint)record.FinalPhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
|
||||
}
|
||||
|
||||
private void ApplyServerControlledVelocityCycle(
|
||||
|
|
@ -5995,6 +6137,14 @@ public sealed class GameWindow : IDisposable
|
|||
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||
DumpMovementTruthServerEcho(update, worldPos);
|
||||
|
||||
bool remoteHardTeleport = update.Guid != _playerServerGuid
|
||||
&& timestamps.TeleportHookRequired;
|
||||
bool remotePlacementRequired = update.Guid != _playerServerGuid
|
||||
&& (remoteHardTeleport
|
||||
|| _remoteTeleportController?.HasPending(update.Guid) == true);
|
||||
if (remoteHardTeleport)
|
||||
RunRemoteTeleportHook(update.Guid, entity.Id);
|
||||
|
||||
// Missiles reconcile the same predicted PhysicsBody in place. The
|
||||
// timestamp gate above already rejected stale corrections; returning
|
||||
// here prevents the generic remote locomotion path from allocating a
|
||||
|
|
@ -6013,6 +6163,13 @@ public sealed class GameWindow : IDisposable
|
|||
_liveCenterY) == true)
|
||||
return;
|
||||
|
||||
if (remotePlacementRequired)
|
||||
{
|
||||
_remoteTeleportController!.BeginPlacement(
|
||||
update.Guid,
|
||||
acceptedSpawn.InstanceSequence);
|
||||
}
|
||||
|
||||
// Capture the pre-update render position for the soft-snap residual
|
||||
// calculation below. Assign entity.Position to the server truth up
|
||||
// front; if we then compute a snap residual, we restore the rendered
|
||||
|
|
@ -6096,6 +6253,50 @@ public sealed class GameWindow : IDisposable
|
|||
rmState.Body.Position = worldPos;
|
||||
}
|
||||
|
||||
// Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a
|
||||
// fresh TELEPORT_TS, or the first placement of a cell-less body,
|
||||
// runs teleport_hook and SetPosition(0x1012) BEFORE the contact
|
||||
// test. In particular, an airborne UP cannot veto or undo this
|
||||
// authoritative destination. Do not pre-clear velocity or invent
|
||||
// grounded flags here: retail SetPosition derives contact from its
|
||||
// transition, while MoveOrTeleport does not consume arg5/arg6.
|
||||
if (remotePlacementRequired)
|
||||
{
|
||||
double teleportTime =
|
||||
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
bool projectionVisible = _liveEntities.TryGetRecord(
|
||||
update.Guid,
|
||||
out LiveEntityRecord teleportRecord)
|
||||
&& teleportRecord.IsSpatiallyVisible;
|
||||
var placement = _remoteTeleportController!.TryApply(
|
||||
rmState,
|
||||
entity,
|
||||
worldPos,
|
||||
p.LandblockId,
|
||||
new System.Numerics.Vector3(
|
||||
p.PositionX,
|
||||
p.PositionY,
|
||||
p.PositionZ),
|
||||
rot,
|
||||
teleportTime,
|
||||
projectionVisible,
|
||||
acceptedSpawn.InstanceSequence,
|
||||
acceptedSpawn.PositionSequence);
|
||||
if (!placement.Applied)
|
||||
{
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
entity.ParentCellId = rmState.CellId;
|
||||
entity.Rotation = rmState.Body.Orientation;
|
||||
if (rmState.CellId != 0)
|
||||
_liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId);
|
||||
return;
|
||||
}
|
||||
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
entity.Rotation = rmState.Body.Orientation;
|
||||
return;
|
||||
}
|
||||
|
||||
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
|
||||
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport
|
||||
// (acclient @ 0x00516330) — airborne no-op, far-snap, near
|
||||
|
|
@ -6284,7 +6485,7 @@ public sealed class GameWindow : IDisposable
|
|||
// DR tick) and for no-Sequencer players. rmState.CellId is the server
|
||||
// cell adopted above (:5735). The per-tick loop keeps it current
|
||||
// between UPs (movement-gated).
|
||||
if (rmState.CellId != 0)
|
||||
if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid))
|
||||
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
|
|
@ -6514,7 +6715,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
|
||||
// the per-tick loop skips). The per-tick loop keeps it current between
|
||||
// UPs, movement-gated. rmState.CellId is the server cell adopted above.
|
||||
if (rmState.CellId != 0)
|
||||
if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid))
|
||||
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
||||
|
||||
|
|
@ -8777,7 +8978,10 @@ public sealed class GameWindow : IDisposable
|
|||
// (which runs after this block). Replaces the AP-79 player poll.
|
||||
_playerHost?.HandleTargetting();
|
||||
|
||||
var result = _playerController.Update((float)dt, input);
|
||||
bool localPlayerHidden = _liveEntities?.IsHidden(_playerServerGuid) == true;
|
||||
var result = localPlayerHidden
|
||||
? _playerController.TickHidden((float)dt)
|
||||
: _playerController.Update((float)dt, input);
|
||||
|
||||
// Update the player entity's position + rotation so it renders at
|
||||
// the physics-resolved location each frame.
|
||||
|
|
@ -8787,7 +8991,8 @@ public sealed class GameWindow : IDisposable
|
|||
pe.ParentCellId = result.CellId;
|
||||
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
|
||||
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
|
||||
SyncLocalPlayerShadow(pe, result.CellId);
|
||||
if (!localPlayerHidden)
|
||||
SyncLocalPlayerShadow(pe, result.CellId);
|
||||
|
||||
// Move the player entity to its current landblock in GpuWorldState
|
||||
// so it doesn't get frustum-culled when the player walks away from
|
||||
|
|
@ -8858,7 +9063,7 @@ public sealed class GameWindow : IDisposable
|
|||
trackedTargetPoint: trackedCombatTarget);
|
||||
|
||||
// Send outbound movement messages to the live server.
|
||||
if (_liveSession is not null)
|
||||
if (_liveSession is not null && !localPlayerHidden)
|
||||
{
|
||||
// Convert world position back to AC wire coordinates.
|
||||
// World origin is _liveCenterX/_liveCenterY; each landblock is 192 units.
|
||||
|
|
@ -9274,6 +9479,14 @@ public sealed class GameWindow : IDisposable
|
|||
// Re-publish without advancing so an extra render between updates still
|
||||
// retains retail's animation-hook versus object-hook phase barrier.
|
||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||
if (_liveEntities is { } liveEntities)
|
||||
{
|
||||
_remotePhysicsUpdater.TickHiddenEntities(
|
||||
liveEntities,
|
||||
_playerServerGuid,
|
||||
(float)deltaSeconds,
|
||||
entity => _effectPoses.UpdateRoot(entity));
|
||||
}
|
||||
_projectileController?.Tick(
|
||||
_physicsScriptGameTime,
|
||||
_liveCenterX,
|
||||
|
|
@ -10386,6 +10599,22 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void RunRemoteTeleportHook(uint serverGuid, uint localEntityId)
|
||||
{
|
||||
_remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote);
|
||||
EntityPhysicsHost? host = _physicsHosts.TryGetValue(serverGuid, out var registered)
|
||||
? registered as EntityPhysicsHost
|
||||
: remote?.Host;
|
||||
AcDream.App.Physics.RemoteTeleportHook.Execute(
|
||||
new AcDream.App.Physics.RemoteTeleportHookActions(
|
||||
CancelMoveTo: error => remote?.Movement.CancelMoveTo(error),
|
||||
UnStick: () => host?.PositionManager.UnStick(),
|
||||
StopInterpolating: () => remote?.Interp.Clear(),
|
||||
UnConstrain: () => host?.PositionManager.UnConstrain(),
|
||||
NotifyTeleported: () => host?.NotifyTeleported(),
|
||||
ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4: advance every animated entity's frame counter by
|
||||
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
|
||||
|
|
@ -10433,6 +10662,21 @@ public sealed class GameWindow : IDisposable
|
|||
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
|
||||
continue;
|
||||
|
||||
// Retail UpdatePositionInternal keeps a narrow Hidden path alive:
|
||||
// no PartArray animation and no physics integration, but
|
||||
// PositionManager::adjust_offset plus the manager tail still run.
|
||||
// Scripts and particles tick in their shared passes below.
|
||||
if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true)
|
||||
{
|
||||
// Hidden suppresses the PartArray/mesh, not the effect owner.
|
||||
// Remote PositionManager composition already ran in the
|
||||
// unconditional live-entity pass; publish again for the local
|
||||
// player and for retained objects without remote motion.
|
||||
// Frozen part-local poses remain unchanged until UnHide.
|
||||
_effectPoses.UpdateRoot(ae.Entity);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||||
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
||||
// entities; without integration, remote chars jitter-hop between
|
||||
|
|
@ -13570,6 +13814,8 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
|
||||
if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord playerRecord) == true)
|
||||
_playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
|
||||
|
||||
// R4-V5: the local player's verbatim MoveToManager — same seam
|
||||
// wiring shape as EnsureRemoteMotionBindings, with three
|
||||
|
|
@ -14192,6 +14438,9 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
_liveEntityLights?.Dispose();
|
||||
_liveEntityLights = null;
|
||||
_liveEntityPresentation?.Dispose();
|
||||
_remoteTeleportController?.Dispose();
|
||||
_remoteTeleportController = null;
|
||||
_entityEffects?.ClearNetworkState();
|
||||
_animationHookFrames?.Clear();
|
||||
_effectPoses.Clear();
|
||||
|
|
|
|||
|
|
@ -103,6 +103,19 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
/// original mixed F754/F755 order.
|
||||
/// </summary>
|
||||
public bool OnLiveEntityReady(uint serverGuid)
|
||||
{
|
||||
if (!PrepareLiveEntityOwner(serverGuid))
|
||||
return false;
|
||||
ReplayPendingForLiveEntity(serverGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the local effect owner without replaying pre-create packets.
|
||||
/// Production uses this narrow barrier so constructor/set_description
|
||||
/// state effects can run before SmartBox replays queued network blobs.
|
||||
/// </summary>
|
||||
public bool PrepareLiveEntityOwner(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity
|
||||
|
|
@ -116,7 +129,15 @@ public sealed class EntityEffectController : IAnimationHookSink
|
|||
_profilesByLocalId[entity.Id] = profile;
|
||||
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
||||
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
||||
TryReplayPending(serverGuid, entity.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary>
|
||||
public bool ReplayPendingForLiveEntity(uint serverGuid)
|
||||
{
|
||||
if (!TryGetReadyLocalId(serverGuid, out uint localId))
|
||||
return false;
|
||||
TryReplayPending(serverGuid, localId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -701,6 +701,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
foreach (var animatedId in animatedEntityIds)
|
||||
{
|
||||
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
|
||||
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
|
||||
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||
if (!EntityMatchesSet(entity, set)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
|
@ -722,6 +723,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
foreach (var entity in entry.Entities)
|
||||
{
|
||||
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
|
||||
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||
if (!EntityMatchesSet(entity, set)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue