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;
///
/// 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.
///
internal sealed class LiveEntityMotionRuntimeController
: ILiveEntityMotionRuntimeBindings
{
private readonly LiveEntityRuntime _liveEntities;
private readonly PhysicsDataCache _physicsDataCache;
private readonly Func _selectionInteractions;
private readonly SelectionState _selection;
private readonly LiveWorldOriginState _origin;
public LiveEntityMotionRuntimeController(
LiveEntityRuntime liveEntities,
PhysicsDataCache physicsDataCache,
Func 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;
}
///
/// R5-V2 — retail CObjectMaint::GetObjectA(id): resolve any entity's
/// by guid, the
/// cross-entity seam every host's GetObjectA uses. If the entity has
/// a bound host (from or
/// EnterPlayerModeNow) return it; otherwise, for any entity that
/// EXISTS in the world, lazily create a minimal position-only host. Retail
/// gives every CPhysicsObj the capacity to host a
/// target_manager — a STATIC object (chest, corpse) must still answer
/// add_voyeur 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).
///
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;
}
///
/// R5-V3 (#171): retail CPartArray::GetRadius/GetHeight
/// (0x005180a0/0x005180b0 — setup->radius/height × scale; ACE
/// PartArray.cs:189-207 reads the same Setup-level fields). Returns
/// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) —
/// ACE's PartArray != null ? … : 0 fallback shape
/// (PhysicsObj.cs:951-952). Live spawns cache their Setup in
/// _physicsDataCache at spawn time (CacheSetup, ~3250), so this is
/// a dictionary hit for every creature.
///
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).
///
/// R5-V4: retail CPhysicsObj::stick_to_object (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
/// PositionManager::StickTo. Unresolvable target → no stick at
/// all (retail's GetObjectA-null path). Retail resolves the target's
/// top-level PARENT first (parent ?? self); acdream's entity
/// table is flat (no client-side parenting), so the guid is used as-is —
/// the same convention as RouteServerMoveTo's TopLevelId.
///
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();
}
///
/// R4-V5: shared retail unpack_movement type-6..9 routing
/// (0x00524440 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 (my_run_rate, unpack @300603/@300660 — plan M13),
/// builds the MovementStruct (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 PerformMovement
/// (MovementManager::PerformMovement 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).
///
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.
///
/// 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.
///
}