Move materialized live-object sidecars and presentation worksets to exact RuntimeEntityKey ownership. Runtime remains the only GUID/incarnation/local-ID authority while hydration, animation, effects, lights, equipped children, renderer resources, visibility, liveness, and teardown resolve exact projection identities. Preserve synchronous callbacks, local-ID allocation order, and current rendering behavior.
486 lines
25 KiB
C#
486 lines
25 KiB
C#
using AcDream.App.Interaction;
|
||
using AcDream.App.Rendering;
|
||
using AcDream.App.World;
|
||
using AcDream.Core.Physics;
|
||
using AcDream.Core.Selection;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.App.Physics;
|
||
|
||
/// <summary>
|
||
/// Focused owner of reusable live CPhysicsObj-style host lookup, remote motion
|
||
/// binding, server MoveTo/sticky routing, setup-cylinder dimensions, and
|
||
/// Hidden target invalidation. It is independent of packet orchestration so
|
||
/// renderer, selection, and movement collaborators share one one-way service.
|
||
/// </summary>
|
||
internal sealed class LiveEntityMotionRuntimeController
|
||
: ILiveEntityMotionRuntimeBindings
|
||
{
|
||
private readonly LiveEntityRuntime _liveEntities;
|
||
private readonly PhysicsDataCache _physicsDataCache;
|
||
private readonly Func<SelectionInteractionController?> _selectionInteractions;
|
||
private readonly SelectionState _selection;
|
||
private readonly LiveWorldOriginState _origin;
|
||
|
||
public LiveEntityMotionRuntimeController(
|
||
LiveEntityRuntime liveEntities,
|
||
PhysicsDataCache physicsDataCache,
|
||
Func<SelectionInteractionController?> selectionInteractions,
|
||
SelectionState selection,
|
||
LiveWorldOriginState origin)
|
||
{
|
||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
|
||
_selectionInteractions = selectionInteractions ?? throw new ArgumentNullException(nameof(selectionInteractions));
|
||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||
}
|
||
internal AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
|
||
RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid)
|
||
{
|
||
AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer;
|
||
if (sequencer is not null && rm.Sink is null)
|
||
{
|
||
rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(sequencer);
|
||
rm.Motion.DefaultSink = rm.Sink;
|
||
}
|
||
// #174 (2026-07-05): the RemoveLinkAnimations seam is retail
|
||
// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
|
||
// CPartArray::HandleEnterWorld 0x00517d70 →
|
||
// MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the
|
||
// sequence's link animations AND drain pending_animations
|
||
// completely (each pop fires MotionDone → CMotionInterp pops its
|
||
// pending_motions node in lockstep). The previous binding was the
|
||
// bare sequence strip — every LeaveGround (jump) stripped the
|
||
// animations that queued manager nodes were counting down on,
|
||
// orphaning them (NumAnims>0, anims gone) and permanently damming
|
||
// BOTH queues: MotionsPending() never drained again, so every
|
||
// armed moveto (ACE's walk-to-door mt-6, the close-range use turn)
|
||
// starved — the "door only works on a fresh session" bug.
|
||
if (sequencer is not null)
|
||
{
|
||
rm.Motion.RemoveLinkAnimations = () => sequencer.Manager.HandleEnterWorld();
|
||
rm.Motion.InitializeMotionTables = () => sequencer.Manager.InitializeState();
|
||
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
||
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
||
rm.Motion.CheckForCompletedMotions = sequencer.Manager.CheckForCompletedMotions;
|
||
}
|
||
|
||
// Host/MoveTo ownership does not depend on a render animation. AP-77
|
||
// deliberately supplies CMotionInterp's headless body fallback when a
|
||
// live object has no PartArray sink, so build the manager once anyway.
|
||
if (rm.Host is not null)
|
||
return rm.Sink;
|
||
if (_liveEntities is not { } liveEntities
|
||
|| !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord)
|
||
|| !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm))
|
||
{
|
||
return rm.Sink;
|
||
}
|
||
// R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
|
||
// wiring (the conformance-tested reference). Positions are WORLD
|
||
// space on both sides (getPosition + the MovementStruct positions
|
||
// the UM router builds) — one consistent space, so the manager's
|
||
// distance/heading math matches the harness exactly.
|
||
// R5-V5: the construction is now the MovementManager facade's
|
||
// MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj
|
||
// backpointers retail's MakeMoveToManager 0x00524000 constructs
|
||
// from); MakeMoveToManager() below invokes it once, preserving the
|
||
// pre-facade eager timing.
|
||
var rmT = rm;
|
||
var mtBody = rm.Body;
|
||
// R5-V3 (#171): real setup cylsphere radii — retail CPartArray::
|
||
// GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve
|
||
// (a dictionary hit) so the read never races the spawn path's
|
||
// CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math
|
||
// (own side) and StickyManager::adjust_offset's own-radius gap term.
|
||
// Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with
|
||
// R5-V3").
|
||
if (!_liveEntities.TryGetWorldEntity(serverGuid, out var selfEntity))
|
||
return rm.Sink;
|
||
// R5-V2: forward-declared so the MoveToManager's target seams route
|
||
// into the entity's TargetManager (retail CPhysicsObj::set_target →
|
||
// TargetManager::SetTarget). Assigned right after the manager is built.
|
||
EntityPhysicsHost host = null!;
|
||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
rm.Movement.MoveToFactory = () =>
|
||
{
|
||
var mtm = new AcDream.Core.Physics.Motion.MoveToManager(
|
||
rm.Motion,
|
||
stopCompletely: () => rmT.Movement.PerformMovement(
|
||
new AcDream.Core.Physics.MovementStruct
|
||
{
|
||
Type = AcDream.Core.Physics.MovementType.StopCompletely,
|
||
}),
|
||
getPosition: () => new AcDream.Core.Physics.Position(
|
||
rmT.CellId, mtBody.Position, mtBody.Orientation),
|
||
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
|
||
mtBody.Orientation),
|
||
setHeading: (h, _) => mtBody.Orientation =
|
||
AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h),
|
||
getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
|
||
getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height,
|
||
contact: () => mtBody.OnWalkable,
|
||
isInterpolating: () => rmT.Interp.IsActive,
|
||
getVelocity: () => mtBody.Velocity,
|
||
getSelfId: () => serverGuid,
|
||
// R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum
|
||
// → the entity's TargetManager (replaces the AP-79 TrackedTarget*
|
||
// poll). The manager passes (0, top_level_id, 0.5, 0.0).
|
||
setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q),
|
||
clearTarget: () => host.ClearTarget(),
|
||
getTargetQuantum: () => host.TargetManager.GetTargetQuantum(),
|
||
setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q),
|
||
// R4-V5: real clock (same epoch-seconds base the per-remote tick uses).
|
||
curTime: NowSeconds);
|
||
// R5-V3 (#171, retires TS-39): bind the sticky seams to the host's
|
||
// PositionManager (host is constructed before MakeMoveToManager
|
||
// invokes this factory) —
|
||
// * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo
|
||
// (retail MoveToManager::BeginNextNode @0x00529d3a);
|
||
// * PerformMovement's head unstick: unstick_from_object →
|
||
// PositionManager::UnStick (MoveToManager.PerformMovement:414).
|
||
mtm.StickTo = (tlid, radius, height) =>
|
||
host.PositionManager.StickTo(tlid, radius, height);
|
||
mtm.Unstick = host.PositionManager.UnStick;
|
||
return mtm;
|
||
};
|
||
// TS-36 (remote side): the interp's interrupt seam is retail's
|
||
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
|
||
// chain (V2's reentrancy tests prove the wiring is inert-safe;
|
||
// R5-V5: the chain now lands on the literal facade relay 0x005241b0).
|
||
rm.Motion.InterruptCurrentMovement =
|
||
() => rmT.Movement.CancelMoveTo(
|
||
AcDream.Core.Physics.WeenieError.ActionCancelled);
|
||
|
||
// R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position
|
||
// is the entity's WORLD position (WorldEntity.Position — exactly the
|
||
// source the AP-79 poll used for a tracked target, so the voyeur system
|
||
// delivers the identical position for a moveto's quantum-0 case).
|
||
// HandleTargetting ticks per frame (added in the per-remote loop);
|
||
// HandleUpdateTarget fans deliveries to this entity's MoveToManager
|
||
// and (R5-V3, inside the host) its PositionManager — retail's
|
||
// CPhysicsObj::HandleUpdateTarget order.
|
||
var configuredHost = new EntityPhysicsHost(
|
||
serverGuid,
|
||
getPosition: () => new AcDream.Core.Physics.Position(
|
||
hostRecord.FullCellId,
|
||
hostRecord.WorldEntity?.Position ?? mtBody.Position,
|
||
mtBody.Orientation),
|
||
getVelocity: () => mtBody.Velocity,
|
||
getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
|
||
inContact: () => mtBody.OnWalkable,
|
||
minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(),
|
||
curTime: NowSeconds,
|
||
physicsTimerTime: NowSeconds,
|
||
getObjectA: ResolvePhysicsHost,
|
||
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
|
||
// MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
|
||
// relay to the moveto side); the host chains the PositionManager
|
||
// leg after it.
|
||
handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
|
||
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
|
||
AcDream.Core.Physics.WeenieError.ActionCancelled));
|
||
host = EntityPhysicsHost.InstallOrRebind(
|
||
liveEntities,
|
||
hostRecord,
|
||
configuredHost);
|
||
rm.MarkFullPhysicsHostBound();
|
||
|
||
// R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
|
||
// MoveToManager via the factory above (host now exists for the
|
||
// sticky binds inside it). The UM funnel head's unstick
|
||
// (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0
|
||
// routing sites) binds on the interp beside it.
|
||
rm.Movement.MakeMoveToManager();
|
||
rm.Motion.UnstickFromObject = host.PositionManager.UnStick;
|
||
return rm.Sink;
|
||
}
|
||
|
||
/// <summary>
|
||
/// R5-V2 — retail <c>CObjectMaint::GetObjectA(id)</c>: resolve any entity's
|
||
/// <see cref="AcDream.Core.Physics.Motion.IPhysicsObjHost"/> by guid, the
|
||
/// cross-entity seam every host's <c>GetObjectA</c> uses. If the entity has
|
||
/// a bound host (from <see cref="EnsureRemoteMotionBindings"/> or
|
||
/// <c>EnterPlayerModeNow</c>) return it; otherwise, for any entity that
|
||
/// EXISTS in the world, lazily create a minimal position-only host. Retail
|
||
/// gives every <c>CPhysicsObj</c> the capacity to host a
|
||
/// <c>target_manager</c> — a STATIC object (chest, corpse) must still answer
|
||
/// <c>add_voyeur</c> so a mover can moveto/stick to it (without this,
|
||
/// auto-walk to a never-animated object would arm the moveto but never
|
||
/// receive the immediate target snapshot, and never start). The minimal
|
||
/// host is registered so subsequent lookups reuse it; it is NOT ticked
|
||
/// (a static object never drifts, so the AddVoyeur immediate snapshot is
|
||
/// all a watcher needs; despawn still fires ExitWorld via the registry).
|
||
/// </summary>
|
||
public AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
|
||
{
|
||
if (_liveEntities is not { } liveEntities)
|
||
return null;
|
||
|
||
bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord);
|
||
if (!isActive)
|
||
return null;
|
||
|
||
// TS-49: CObjCell::hide_object removes Hidden objects from the
|
||
// relationship surface until DetectionManager is ported. Pending,
|
||
// attached, and otherwise non-render-visible active objects remain in
|
||
// CObjectMaint's object table and must still resolve here.
|
||
if (liveEntities.IsHidden(id))
|
||
{
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
{
|
||
Console.WriteLine(
|
||
$"[autowalk-host-miss] object=0x{id:X8} "
|
||
+ $"materialized={_liveEntities.ContainsWorldEntity(id)} "
|
||
+ $"registered={liveEntities.TryGetPhysicsHost(id, out _)} "
|
||
+ $"hidden={liveEntities.IsHidden(id)}");
|
||
}
|
||
return null;
|
||
}
|
||
if (liveEntities.TryGetPhysicsHost(id, out var existing))
|
||
return existing;
|
||
|
||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
var minimal = EntityPhysicsHost.CreateMinimal(
|
||
activeRecord,
|
||
ResolvePhysicsHost,
|
||
NowSeconds);
|
||
liveEntities.InstallPhysicsHost(activeRecord, minimal);
|
||
return minimal;
|
||
}
|
||
|
||
/// <summary>
|
||
/// R5-V3 (#171): retail <c>CPartArray::GetRadius</c>/<c>GetHeight</c>
|
||
/// (0x005180a0/0x005180b0 — <c>setup->radius/height × scale</c>; ACE
|
||
/// <c>PartArray.cs:189-207</c> reads the same Setup-level fields). Returns
|
||
/// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) —
|
||
/// ACE's <c>PartArray != null ? … : 0</c> fallback shape
|
||
/// (<c>PhysicsObj.cs:951-952</c>). Live spawns cache their Setup in
|
||
/// <c>_physicsDataCache</c> at spawn time (CacheSetup, ~3250), so this is
|
||
/// a dictionary hit for every creature.
|
||
/// </summary>
|
||
public (float Radius, float Height) GetSetupCylinder(
|
||
uint serverGuid, AcDream.Core.World.WorldEntity entity)
|
||
{
|
||
FlatSetupCollision? setup =
|
||
_physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId);
|
||
if (setup is null)
|
||
return (0f, 0f);
|
||
// Live spawns bake ObjScale into MeshRefs and never populate
|
||
// WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the
|
||
// authoritative per-entity scale is the SPAWN RECORD's ObjScale, the
|
||
// same source the collision registration's entScale uses (~4137).
|
||
// entity.Scale remains the fallback for non-spawn entities (scenery —
|
||
// never a chase/sticky participant). Diff-review find: without this,
|
||
// a server-scaled creature variant read unscaled radii and kept the
|
||
// #171 interpenetration exactly for scaled bodies.
|
||
float scale =
|
||
_liveEntities.Snapshots.TryGetValue(serverGuid, out var sp)
|
||
&& sp.ObjScale is { } objScale && objScale > 0f
|
||
? objScale
|
||
: (entity.Scale > 0f ? entity.Scale : 1f);
|
||
return (setup.Radius * scale, setup.Height * scale);
|
||
}
|
||
|
||
// #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to
|
||
// AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no
|
||
// caller outside the DR tick; SyncRemoteShadowToBody is now called back via
|
||
// _remotePhysicsUpdater from the NPC UP-branch tail (below).
|
||
|
||
/// <summary>
|
||
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
|
||
/// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
|
||
/// motionFlags 0x1 trailer guid): resolve the target, read its PartArray
|
||
/// radius/height (0 when shapeless — retail's null-PartArray arm), then
|
||
/// <c>PositionManager::StickTo</c>. Unresolvable target → no stick at
|
||
/// all (retail's GetObjectA-null path). Retail resolves the target's
|
||
/// top-level PARENT first (<c>parent ?? self</c>); acdream's entity
|
||
/// table is flat (no client-side parenting), so the guid is used as-is —
|
||
/// the same convention as RouteServerMoveTo's TopLevelId.
|
||
/// </summary>
|
||
public void StickToObjectFromWire(
|
||
AcDream.Core.Physics.Motion.IPhysicsObjHost? host,
|
||
uint targetGuid)
|
||
{
|
||
if (host is not EntityPhysicsHost entityHost)
|
||
return;
|
||
if (_liveEntities is not { } liveEntities
|
||
|| !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt))
|
||
return;
|
||
var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
|
||
entityHost.PositionManager.StickTo(targetGuid, radius, height);
|
||
}
|
||
|
||
public void ClearTargetForHiddenEntity(uint serverGuid)
|
||
{
|
||
if (_selectionInteractions() is { } interactions)
|
||
interactions.OnEntityHidden(serverGuid);
|
||
else if (_selection.SelectedObjectId == serverGuid)
|
||
_selection.Clear(
|
||
AcDream.Core.Selection.SelectionChangeSource.System,
|
||
AcDream.Core.Selection.SelectionChangeReason.Cleared);
|
||
|
||
if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true
|
||
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
|
||
hiddenEntityHost.NotifyHidden();
|
||
}
|
||
|
||
/// <summary>
|
||
/// R4-V5: shared retail <c>unpack_movement</c> type-6..9 routing
|
||
/// (<c>0x00524440</c> cases 6/7/8/9, decomp §2f) — one body for remotes
|
||
/// (R4-V4) and the local player (R4-V5). Writes the wire run rate to
|
||
/// the interp (<c>my_run_rate</c>, unpack @300603/@300660 — plan M13),
|
||
/// builds the <c>MovementStruct</c> (mt 6/8 resolve the target guid
|
||
/// against the entity table; unresolvable degrades to
|
||
/// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f —
|
||
/// NOT an error), and calls the R5-V5 facade's <c>PerformMovement</c>
|
||
/// (<c>MovementManager::PerformMovement</c> 0x005240d0 — types 6-9
|
||
/// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager
|
||
/// at each head, a no-op here since the bind sites create eagerly).
|
||
/// Returns true when the event was a type-6..9 moveto (consumed); false
|
||
/// for every other movement type (caller falls through to its funnel /
|
||
/// skip posture).
|
||
/// </summary>
|
||
public bool RouteServerMoveTo(
|
||
AcDream.Core.Physics.Motion.MovementManager movement,
|
||
uint cellId,
|
||
AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
|
||
{
|
||
if (update.MotionState.IsServerControlledMoveTo
|
||
&& update.MotionState.MoveToPath is { } path)
|
||
{
|
||
// my_run_rate write (unpack_movement @300603).
|
||
if (update.MotionState.MoveToRunRate is { } mtRunRate)
|
||
movement.Minterp.MyRunRate = mtRunRate;
|
||
|
||
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
|
||
.OriginToWorld(
|
||
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
|
||
_origin.CenterX, _origin.CenterY);
|
||
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
|
||
path.Bitfield,
|
||
path.DistanceToObject,
|
||
path.MinDistance,
|
||
path.FailDistance,
|
||
update.MotionState.MoveToSpeed ?? 1f,
|
||
path.WalkRunThreshold,
|
||
path.DesiredHeading);
|
||
|
||
var ms = new AcDream.Core.Physics.MovementStruct
|
||
{
|
||
Params = mp,
|
||
};
|
||
// mt 6 with a resolvable target → MoveToObject (the P4 tracker
|
||
// feeds position updates per tick); else degrade to
|
||
// MoveToPosition at the wire origin (§2f).
|
||
if (update.MotionState.MovementType == 6
|
||
&& path.TargetGuid is { } tgtGuid
|
||
&& _liveEntities is { } liveMoveEntities
|
||
&& liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt))
|
||
{
|
||
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
|
||
ms.ObjectId = tgtGuid;
|
||
ms.TopLevelId = tgtGuid;
|
||
// R5-V3 (#171): retail resolves the TARGET object's PartArray
|
||
// radius/height at the MoveToObject call site
|
||
// (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE
|
||
// PhysicsObj.cs:951-952) — they become SoughtObjectRadius/
|
||
// Height, feeding GetCurrentDistance's edge-to-edge arrival
|
||
// (UseSpheres) and the sticky-arrival handoff's target radius.
|
||
// Was unset (0): every attacker closed ~one body-radius deeper
|
||
// than retail before stopping — the #171 dogpile term.
|
||
(ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt);
|
||
ms.Pos = new AcDream.Core.Physics.Position(
|
||
cellId, tgtEnt.Position,
|
||
System.Numerics.Quaternion.Identity);
|
||
}
|
||
else
|
||
{
|
||
ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
|
||
ms.Pos = new AcDream.Core.Physics.Position(
|
||
cellId, destWorld,
|
||
System.Numerics.Quaternion.Identity);
|
||
}
|
||
movement.PerformMovement(ms);
|
||
return true;
|
||
}
|
||
|
||
if (update.MotionState.IsServerControlledTurnTo
|
||
&& update.MotionState.TurnToPath is { } turnPath)
|
||
{
|
||
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
|
||
turnPath.Bitfield,
|
||
turnPath.Speed,
|
||
turnPath.DesiredHeading);
|
||
|
||
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
|
||
if (update.MotionState.MovementType == 8
|
||
&& turnPath.TargetGuid is { } turnTgt
|
||
&& _liveEntities is { } liveTurnEntities
|
||
&& liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt))
|
||
{
|
||
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
|
||
ms.ObjectId = turnTgt;
|
||
ms.TopLevelId = turnTgt;
|
||
ms.Pos = new AcDream.Core.Physics.Position(
|
||
cellId, turnEnt.Position,
|
||
System.Numerics.Quaternion.Identity);
|
||
}
|
||
else
|
||
{
|
||
ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
|
||
// Retail's mt-8 unresolvable-object fallback substitutes the
|
||
// STANDALONE wire heading into the params before degrading
|
||
// (decomp §2f case 8: `params.desired_heading = wire_heading`).
|
||
// Invisible against ACE (P6: both heading fields written from
|
||
// the same source) but required for the verbatim degrade —
|
||
// closes the V4 carry-over the adversarial review caught
|
||
// (TurnToPathData.WireHeading was parsed but never consumed).
|
||
if (update.MotionState.MovementType == 8
|
||
&& turnPath.WireHeading is { } wireHeading)
|
||
{
|
||
mp.DesiredHeading = wireHeading;
|
||
}
|
||
}
|
||
movement.PerformMovement(ms);
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
{
|
||
string target = turnPath.TargetGuid is { } targetGuid
|
||
? $"0x{targetGuid:X8}" : "null";
|
||
bool targetVisible = turnPath.TargetGuid is { } visibleGuid
|
||
&& _liveEntities.TryGetInteractionEligibleEntity(
|
||
visibleGuid,
|
||
out _);
|
||
bool targetHost = turnPath.TargetGuid is { } hostGuid
|
||
&& _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true;
|
||
var moveTo = movement.MoveTo;
|
||
Console.WriteLine(
|
||
$"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} "
|
||
+ $"routed={ms.Type} target={target} visible={targetVisible} "
|
||
+ $"host={targetHost} stop={mp.StopCompletelyFlag} "
|
||
+ $"initialized={moveTo?.Initialized ?? false} "
|
||
+ $"nodes={moveTo?.PendingActions.Count() ?? 0} "
|
||
+ $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} "
|
||
+ $"pendingMotions={movement.Minterp.MotionsPending()}");
|
||
}
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime
|
||
// 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is
|
||
// gone, TargetManager voyeur delivers positions) moved to
|
||
// RemotePhysicsUpdater. The DR tick was its only caller.
|
||
|
||
/// <summary>
|
||
/// Phase 6.6: the server says an entity's motion has changed. Look up
|
||
/// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the
|
||
/// new (stance, forward-command) override, and if the cycle is still
|
||
/// animated, swap in the new animation/frame range. Entities not in
|
||
/// the animated map (static props, entities rejected at spawn time)
|
||
/// are simply ignored — there's nothing to tick for them.
|
||
/// </summary>
|
||
}
|