refactor(world): extract live entity network updates

Move Position, Vector, State, Movement, and equal-generation CreateObject routing out of GameWindow while preserving per-channel authority, ForcePosition acknowledgement, and motion-runtime ownership. Add adversarial authority and exact-wire coverage so reentrant updates and GUID reuse cannot publish stale state.
This commit is contained in:
Erik 2026-07-21 19:11:49 +02:00
parent c203e4799a
commit aa90c64666
19 changed files with 3701 additions and 2241 deletions

View file

@ -121,6 +121,47 @@ public sealed class LocalPlayerOutboundController
controller.SimTimeSeconds);
}
/// <summary>
/// Retail ForcePosition acknowledgement sent immediately after the local
/// body is blipped. Unlike the periodic post-network sender, this bypasses
/// the elapsed/difference predicate but still requires a valid grounded
/// canonical frame.
/// </summary>
internal void SendImmediatePosition(
WorldSession? session,
PlayerMovementController? controller)
{
if (session is null
|| controller is null
|| !controller.CanSendPositionEvent)
{
return;
}
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
return;
uint cellId = outboundPosition.ObjCellId;
Vector3 position = outboundPosition.Frame.Origin;
Quaternion rotation = outboundPosition.Frame.Orientation;
uint sequence = session.NextGameActionSequence();
byte[] body = AutonomousPosition.Build(
gameActionSequence: sequence,
cellId: cellId,
position: position,
rotation: rotation,
instanceSequence: session.InstanceSequence,
serverControlSequence: session.ServerControlSequence,
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence,
lastContact: 1);
session.SendGameAction(body);
controller.NotePositionSent(
outboundPosition,
controller.ContactPlane,
controller.SimTimeSeconds);
}
public bool TrySendMovement(
WorldSession? session,
PlayerMovementController? controller,

View file

@ -0,0 +1,64 @@
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
namespace AcDream.App.Physics;
/// <summary>
/// Narrow reverse seam for collaborators constructed before the live network
/// owner. It carries no state or identity: every call resolves the one bound
/// update-thread owner, and use before bind fails immediately.
/// </summary>
internal interface ILiveEntityMotionRuntimeBindings
{
(float Radius, float Height) GetSetupCylinder(uint serverGuid, WorldEntity entity);
bool RouteServerMoveTo(
MovementManager movement,
uint cellId,
WorldSession.EntityMotionUpdate update);
void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid);
void ClearTargetForHiddenEntity(uint serverGuid);
IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid);
}
internal sealed class DeferredLiveEntityMotionRuntimeBindings
: ILiveEntityMotionRuntimeBindings
{
private ILiveEntityMotionRuntimeBindings? _target;
public void Bind(ILiveEntityMotionRuntimeBindings target)
{
ArgumentNullException.ThrowIfNull(target);
if (_target is not null)
{
throw new InvalidOperationException(
"Live entity motion runtime bindings are already bound.");
}
_target = target;
}
private ILiveEntityMotionRuntimeBindings Target =>
_target ?? throw new InvalidOperationException(
"Live entity motion runtime bindings were used before binding.");
public (float Radius, float Height) GetSetupCylinder(
uint serverGuid,
WorldEntity entity) => Target.GetSetupCylinder(serverGuid, entity);
public bool RouteServerMoveTo(
MovementManager movement,
uint cellId,
WorldSession.EntityMotionUpdate update) =>
Target.RouteServerMoveTo(movement, cellId, update);
public void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid) =>
Target.StickToObjectFromWire(host, targetGuid);
public void ClearTargetForHiddenEntity(uint serverGuid) =>
Target.ClearTargetForHiddenEntity(serverGuid);
public IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid) =>
Target.ResolvePhysicsHost(serverGuid);
}

View file

@ -0,0 +1,209 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.Physics;
internal readonly record struct AcceptedMotionNetworkUpdate(
WorldSession.EntityMotionUpdate Update,
LiveEntityRecord Record,
AcceptedPhysicsTimestamps Timestamps,
ulong MovementAuthorityVersion,
ulong VelocityAuthorityVersion);
internal readonly record struct AcceptedVectorNetworkUpdate(
VectorUpdate.Parsed Update,
LiveEntityRecord Record,
ulong VectorAuthorityVersion,
ulong VelocityAuthorityVersion);
internal readonly record struct AcceptedStateNetworkUpdate(
SetState.Parsed Update,
LiveEntityRecord Record,
ulong StateAuthorityVersion);
internal readonly record struct AcceptedPositionNetworkUpdate(
WorldSession.EntityPositionUpdate Update,
LiveEntityRecord Record,
WorldSession.EntitySpawn Spawn,
AcceptedPhysicsTimestamps Timestamps,
PositionTimestampDisposition TimestampDisposition,
ulong PositionAuthorityVersion,
ulong VelocityAuthorityVersion);
/// <summary>
/// Owns the one retail timestamp/authority admission edge for live inbound
/// Movement, Vector, State, and Position packets. The accepted context pins
/// the exact record and channel versions before any re-entrant App callback
/// can run; channel presenters must revalidate those captures after callbacks.
/// </summary>
internal sealed class LiveEntityInboundAuthorityGate
{
private readonly LiveEntityRuntime _liveEntities;
private readonly Action<uint, AcceptedPhysicsTimestamps> _publishTimestamps;
public LiveEntityInboundAuthorityGate(
LiveEntityRuntime liveEntities,
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_publishTimestamps = publishTimestamps
?? throw new ArgumentNullException(nameof(publishTimestamps));
}
internal uint? LastLivePlayerLandblockId { get; private set; }
internal void ResetSessionState() => LastLivePlayerLandblockId = null;
internal bool TryAcceptMotion(
WorldSession.EntityMotionUpdate update,
bool retainPayload,
out AcceptedMotionNetworkUpdate accepted,
out bool timestampAccepted)
{
accepted = default;
timestampAccepted = _liveEntities.TryApplyMotion(
update,
retainPayload,
out _,
out AcceptedPhysicsTimestamps timestamps);
if (!timestampAccepted)
return false;
LiveEntityRecord? record = null;
ulong movementAuthorityVersion = 0;
ulong velocityAuthorityVersion = 0;
if (retainPayload)
{
if (!_liveEntities.TryGetRecord(update.Guid, out record))
return false;
movementAuthorityVersion = record.MovementAuthorityVersion;
velocityAuthorityVersion = record.VelocityAuthorityVersion;
}
_publishTimestamps(update.Guid, timestamps);
if (!retainPayload)
return false;
if (!_liveEntities.IsCurrentMovementAuthority(
record!,
movementAuthorityVersion)
|| !_liveEntities.IsCurrentVelocityAuthority(
record!,
velocityAuthorityVersion))
{
return false;
}
accepted = new AcceptedMotionNetworkUpdate(
update,
record!,
timestamps,
movementAuthorityVersion,
velocityAuthorityVersion);
return true;
}
internal bool TryAcceptVector(
VectorUpdate.Parsed update,
bool payloadIsValid,
out AcceptedVectorNetworkUpdate accepted)
{
accepted = default;
if (!payloadIsValid
|| !_liveEntities.TryApplyVector(update, out _)
|| !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
{
return false;
}
accepted = new AcceptedVectorNetworkUpdate(
update,
record,
record.VectorAuthorityVersion,
record.VelocityAuthorityVersion);
return true;
}
internal bool TryAcceptState(
SetState.Parsed update,
out AcceptedStateNetworkUpdate accepted)
{
accepted = default;
if (!_liveEntities.TryApplyState(update, out _, out _)
|| !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
{
return false;
}
accepted = new AcceptedStateNetworkUpdate(
update,
record,
record.StateAuthorityVersion);
return true;
}
internal bool TryAcceptPosition(
WorldSession.EntityPositionUpdate update,
uint localPlayerGuid,
Quaternion? forcePositionRotation,
Vector3? currentLocalVelocity,
bool payloadIsValid,
out AcceptedPositionNetworkUpdate accepted)
{
accepted = default;
if (!payloadIsValid)
return false;
bool isLocalPlayer = update.Guid == localPlayerGuid;
bool known = _liveEntities.TryApplyPosition(
update,
isLocalPlayer,
isLocalPlayer ? forcePositionRotation : null,
isLocalPlayer ? currentLocalVelocity : null,
out PositionTimestampDisposition disposition,
out WorldSession.EntitySpawn spawn,
out AcceptedPhysicsTimestamps timestamps);
if (!known)
{
if (isLocalPlayer)
LastLivePlayerLandblockId = update.Position.LandblockId;
return false;
}
LiveEntityRecord? record = null;
ulong positionAuthorityVersion = 0;
ulong velocityAuthorityVersion = 0;
if (disposition is not PositionTimestampDisposition.Rejected)
{
if (!_liveEntities.TryGetRecord(update.Guid, out record))
return false;
positionAuthorityVersion = record.PositionAuthorityVersion;
velocityAuthorityVersion = record.VelocityAuthorityVersion;
}
_publishTimestamps(update.Guid, timestamps);
if (disposition is PositionTimestampDisposition.Rejected)
return false;
if (!_liveEntities.IsCurrentPositionAuthority(
record!,
positionAuthorityVersion))
{
return false;
}
accepted = new AcceptedPositionNetworkUpdate(
update,
record!,
spawn,
timestamps,
disposition,
positionAuthorityVersion,
velocityAuthorityVersion);
return true;
}
internal void ObserveAcceptedLocalPosition(uint landblockId) =>
LastLivePlayerLandblockId = landblockId;
}

View file

@ -0,0 +1,488 @@
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;
private IReadOnlyDictionary<uint, WorldEntity> _entitiesByServerGuid =>
_liveEntities.MaterializedWorldEntities;
private IReadOnlyDictionary<uint, WorldEntity> _visibleEntitiesByServerGuid =>
_liveEntities.WorldEntities;
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 (!_entitiesByServerGuid.TryGetValue(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={_entitiesByServerGuid.ContainsKey(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)
{
var setup = _physicsDataCache.GetSetup(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
&& _visibleEntitiesByServerGuid.ContainsKey(visibleGuid);
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>
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
namespace AcDream.App.Physics;
internal enum LiveEntityVectorRoute
{
Projectile,
CanonicalBody,
OrdinaryRemote,
}
/// <summary>
/// Pins retail's one-CPhysicsObj vector-update priority. A missile owns its
/// canonical body first; otherwise a retained static body consumes the wire
/// vector; only an ordinary remote reaches MovementManager storage.
/// </summary>
internal static class LiveEntityVectorRouter
{
internal static LiveEntityVectorRoute Route(
Func<bool> tryProjectile,
Func<bool> tryCanonicalBody,
Action applyOrdinaryRemote)
{
ArgumentNullException.ThrowIfNull(tryProjectile);
ArgumentNullException.ThrowIfNull(tryCanonicalBody);
ArgumentNullException.ThrowIfNull(applyOrdinaryRemote);
if (tryProjectile())
return LiveEntityVectorRoute.Projectile;
if (tryCanonicalBody())
return LiveEntityVectorRoute.CanonicalBody;
applyOrdinaryRemote();
return LiveEntityVectorRoute.OrdinaryRemote;
}
}

View file

@ -0,0 +1,29 @@
namespace AcDream.App.Physics;
/// <summary>
/// Preserves retail ForcePosition's atomic local order: validate the accepted
/// Position authority, blip once, acknowledge once, then stop if the
/// acknowledgement synchronously displaced that authority.
/// </summary>
internal static class LocalForcePositionTransaction
{
internal static bool Apply(
bool isForcePosition,
Func<bool> isCurrent,
Action blip,
Action acknowledge)
{
ArgumentNullException.ThrowIfNull(isCurrent);
ArgumentNullException.ThrowIfNull(blip);
ArgumentNullException.ThrowIfNull(acknowledge);
if (!isForcePosition)
return true;
if (!isCurrent())
return false;
blip();
acknowledge();
return isCurrent();
}
}

View file

@ -24,11 +24,10 @@ namespace AcDream.App.Physics;
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
///
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
/// <c>GetSetupCylinder</c> (a general Setup-dimension helper with ~9 callers,
/// incl. the local player's own cylinder — kept on GameWindow) and
/// <c>ApplyServerControlledVelocityCycle</c> (anim-cycle selection, also called
/// from the UP handler) arrive as delegates. <c>SyncRemoteShadowToBody</c>
/// <para>Shared policy arrives through focused Physics delegates:
/// <c>GetSetupCylinder</c> is owned by <c>LiveEntityMotionRuntimeController</c>,
/// while <c>ApplyServerControlledVelocityCycle</c> is stateless policy also
/// consumed by the UpdatePosition route. <c>SyncRemoteShadowToBody</c>
/// (remote-physics-specific) moved here and is called back from the UP-branch
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
/// callers and moved here outright.</para>

View file

@ -0,0 +1,81 @@
using AcDream.App.Rendering;
namespace AcDream.App.Physics;
/// <summary>
/// Stateless AP-80 compatibility refinement for server-controlled non-player
/// locomotion. Kept outside network routing because the per-frame remote
/// physics updater and accepted Position tail consume the same operation.
/// </summary>
internal static class RemoteServerControlledVelocityCycle
{
private static bool IsPlayerGuid(uint guid) =>
(guid & 0xFF000000u) == 0x50000000u;
public static void Apply(
uint serverGuid,
LiveEntityAnimationState ae,
RemoteMotion rm,
System.Numerics.Vector3 velocity)
{
if (rm.Airborne) return;
if (ae.Sequencer is null) return;
// R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's
// get_command dispatch). Velocity-estimated cycle planning would
// fight it — same rationale as the pre-V4 ServerMoveToActive guard.
if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return;
if (IsPlayerGuid(serverGuid))
{
// L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO
// pace-derived cycle refinement — retail has no such mechanism
// anywhere in its inbound pipeline (deviation map DEV-2, two
// independent decomp dives + ACE cross-check). The #39-era
// premise ("retail's outbound goes silent on Shift toggle") was
// refuted at all three oracles + the S0 live capture: retail
// sends a fresh MoveToState on HoldRun toggle while moving
// (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE
// rebroadcasts every MoveToState (GameActionMoveToState.cs:36),
// and the wire shows explicit 0x0005↔0x0007 UMs on each toggle
// (launch-s0-wireprobe.log). The refinement layer's re-promote
// after legitimate flags=0 Ready UMs was itself the observed
// Ready↔Run thrash. Cycle changes for player remotes come from
// UpdateMotion ONLY, exactly like retail; position error is the
// InterpolationManager chase's job.
//
// NPC/monster remotes below keep PlanFromVelocity until S6
// unifies all entity classes onto the CMotionInterp funnel.
return;
}
uint currentMotion = ae.Sequencer.CurrentMotion;
// AP-80 is a position-only compatibility adaptation, not an
// animation authority. Restrict it to Ready/Walk/Run so late death
// position deltas cannot replace the authoritative Dead substate.
if (!AcDream.Core.Physics.ServerControlledLocomotion
.CanApplyVelocityCycle(currentMotion))
return;
var plan = AcDream.Core.Physics.ServerControlledLocomotion
.PlanFromVelocity(velocity);
uint style = ae.Sequencer.CurrentStyle != 0
? ae.Sequencer.CurrentStyle
: 0x8000003Du;
// D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether
// ApplyServerControlledVelocityCycle is racing UpdateMotion-driven
// SetCycle for non-player remotes (NPCs / monsters).
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
System.Console.WriteLine(
$"[UPCYCLE] guid={serverGuid:X8} "
+ $"vel=({velocity.X:F2},{velocity.Y:F2},{velocity.Z:F2}) "
+ $"|v|={velocity.Length():F2} "
+ $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} "
+ $"prev=0x{currentMotion:X8} "
+ $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}");
}
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,48 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.World;
internal interface ILiveEntitySameGenerationUpdateSink
{
void OnDescription(uint ownerGuid, PhysicsSpawnData description);
void OnAppearance(ObjDescEvent.Parsed appearance);
void OnParent(CreateParentUpdate parent);
void OnPosition(WorldSession.EntityPositionUpdate position);
void OnPickup(PickupEvent.Parsed pickup);
void OnMovement(WorldSession.EntityMotionUpdate movement);
void OnState(SetState.Parsed state);
void OnVector(VectorUpdate.Parsed vector);
}
/// <summary>
/// Pins the equal-generation <c>CObjectMaint::CreateObject</c> tail order from
/// retail <c>SmartBox::HandleCreateObject @ 0x00454C80</c>. The leading
/// PhysicsDesc effect-profile refresh is the isolated AP-119 adaptation; the
/// retail tail then remains ObjDesc, one Position relation, Movement, State,
/// and Vector. Each channel still owns an independent timestamp gate.
/// </summary>
internal static class LiveEntitySameGenerationUpdateRouter
{
public static void Apply(
SameGenerationCreateObjectEvents refresh,
ILiveEntitySameGenerationUpdateSink sink)
{
ArgumentNullException.ThrowIfNull(sink);
sink.OnDescription(refresh.Appearance.Guid, refresh.Description);
sink.OnAppearance(refresh.Appearance);
if (refresh.Parent is { } parent)
sink.OnParent(parent);
else if (refresh.Position is { } position)
sink.OnPosition(position);
else if (refresh.Pickup is { } pickup)
sink.OnPickup(pickup);
if (refresh.Movement is { } movement)
sink.OnMovement(movement);
sink.OnState(refresh.State);
sink.OnVector(refresh.Vector);
}
}