acdream/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
Erik 420e5eea70 refactor(app): key live projections by runtime identity
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.
2026-07-25 21:50:58 +02:00

486 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-&gt;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>
}