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 = EntityPhysicsHostComposition.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 = EntityPhysicsHostComposition.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. /// }