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:
parent
c203e4799a
commit
aa90c64666
19 changed files with 3701 additions and 2241 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
209
src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs
Normal file
209
src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs
Normal 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;
|
||||
}
|
||||
488
src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
Normal file
488
src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
Normal 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->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>
|
||||
}
|
||||
1789
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
Normal file
1789
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
Normal file
File diff suppressed because it is too large
Load diff
34
src/AcDream.App/Physics/LiveEntityVectorRoute.cs
Normal file
34
src/AcDream.App/Physics/LiveEntityVectorRoute.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/AcDream.App/Physics/LocalForcePositionTransaction.cs
Normal file
29
src/AcDream.App/Physics/LocalForcePositionTransaction.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue