feat(physics): R5-V1 — port PositionManager/Sticky/Constraint + TargetManager (Core, unwired)
The retail movement-manager family the R4 MoveToManager port left as do-not-invent seams (decomp §9f/§9g). Faithful C# ports of retail's PositionManager facade + StickyManager + ConstraintManager + the TargetManager voyeur system, with full conformance tests. NO wiring yet — purely additive, no behavior change. Wiring (retiring TS-39 sticky + AP-79 target adapter) is R5-V2/V3. New Core classes (src/AcDream.Core/Physics/Motion/): - StickyManager (0x00555400): follow-a-target steering. adjust_offset's dense x87 mush decoded via ACE (StickyRadius 0.3, StickyTime 1.0, follow speed ×5 / fallback 15) — speed-clamped signed-distance steer + bounded turn-to-face; 1 s watchdog; Ok→initialized / non-Ok→teardown. - ConstraintManager (0x00556090): the server-position rubber-band leash. 90% IsFullyConstrained jump gate + grounded linear brake taper. Structural only — acdream never ARMS it (retail arms from SmartBox::HandleReceivedPosition, which acdream lacks, with two x87 constants BN elided). IsFullyConstrained stays false = TS-35 behavior; leash-arming + the unknown constants are a deferred issue. - PositionManager facade (0x00555160): lazy Sticky/Constraint + fan-out. - TargetManager (0x0051a370) + TargettedVoyeurInfo: the peer-to-peer voyeur subscription system (0.5 s throttle, 10 s staleness, send-on-drift-past-radius, dead-reckon GetInterpolatedPosition). A faithful superset of the AP-79 adapter — SetTarget subscribes ON the target; the target's HandleTargetting pushes updates back. - IPhysicsObjHost: the CPhysicsObj back-pointer seam (position/velocity/ radius/contact/GetObjectA + target-tracking fan-out) the App wires per entity in V2/V3. MotionDeltaFrame: mutable retail-Frame delta accumulator. Supporting: - TargetInfo extended to the full retail 10-field struct (additive defaults keep the R4 4-arg call sites compiling). - MoveToMath: signed CylinderDistanceNoZ, NormalizeCheckSmall, GlobalToLocalVec. - Rename: the misnamed AcDream.Core.Physics.PositionManager (a remote anim+interp per-frame combiner, NOT the retail facade) → RemoteMotion Combiner, freeing the name and removing the ambiguity that breaks every file importing both Physics + Physics.Motion (GameWindow will in V2/V3). Tests: 42 new conformance cases (Sticky/Constraint/Position facade + TargetManager incl. the full cross-entity voyeur round-trip). Full suite 4006 green (+2 skipped), no regressions. Decomp + ACE cross-ref + port plan: docs/research/2026-07-03-r5-managers/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
517bbfdae4
commit
3d89446d98
25 changed files with 7279 additions and 12 deletions
|
|
@ -548,8 +548,8 @@ public sealed class GameWindow : IDisposable
|
|||
/// queue catch-up REPLACES anim when active; anim stands when queue
|
||||
/// is idle.
|
||||
/// </summary>
|
||||
public AcDream.Core.Physics.PositionManager Position { get; } =
|
||||
new AcDream.Core.Physics.PositionManager();
|
||||
public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } =
|
||||
new AcDream.Core.Physics.RemoteMotionCombiner();
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
|
||||
|
|
|
|||
|
|
@ -457,7 +457,7 @@ public sealed class AnimationSequencer
|
|||
/// <see cref="MotionTableManager.PerformMovement"/>, then (on a
|
||||
/// successful InterpretedCommand) the locomotion velocity synthesis
|
||||
/// (register AP-75; the consumers are remote body translation via
|
||||
/// PositionManager.ComputeOffset and the local Option-B
|
||||
/// RemoteMotionCombiner.ComputeOffset and the local Option-B
|
||||
/// get_state_velocity — retire in R6 when root motion drives the body).
|
||||
/// Omega is deliberately NOT synthesized here: remote rotation is the
|
||||
/// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6);
|
||||
|
|
|
|||
120
src/AcDream.Core/Physics/Motion/ConstraintManager.cs
Normal file
120
src/AcDream.Core/Physics/Motion/ConstraintManager.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — verbatim port of retail's <c>ConstraintManager</c> (acclient.h:31529,
|
||||
/// struct #3467; decomp 0x00556090-0x005562xx,
|
||||
/// <c>r5-constraintmanager-decomp.md</c>). The server-position <b>rubber-band
|
||||
/// leash</b>: while the owning object is in ground contact it progressively
|
||||
/// resists movement past a start→max distance band from a pinned anchor, and
|
||||
/// hard-clamps at the max. Read as a jump gate by
|
||||
/// <c>CMotionInterp::jump_is_allowed</c> (block jump while
|
||||
/// <see cref="IsFullyConstrained"/>).
|
||||
///
|
||||
/// <para><b>Arming is UNPORTED in acdream (R5).</b> Retail arms the leash ONLY
|
||||
/// from <c>SmartBox::HandleReceivedPosition</c> (on every inbound server
|
||||
/// position packet) with two constants from
|
||||
/// <c>CPhysicsObj::GetStart/MaxConstraintDistance</c> whose values BN elided
|
||||
/// (x87 returns — unknown, need a cdb read). acdream's position reconciliation
|
||||
/// is not SmartBox, so nothing calls <see cref="ConstrainTo"/> — the leash
|
||||
/// stays disarmed and <see cref="IsFullyConstrained"/> stays false, matching
|
||||
/// register TS-35's current stub behavior. The class is ported for structural
|
||||
/// completeness of <see cref="PositionManager"/>; the leash-arming port + the
|
||||
/// two unknown constants are a deferred issue (port-plan §Constraint scope).</para>
|
||||
/// </summary>
|
||||
public sealed class ConstraintManager
|
||||
{
|
||||
private readonly IPhysicsObjHost _host;
|
||||
|
||||
/// <summary>+0x04 retail <c>is_constrained</c>.</summary>
|
||||
public bool IsConstrained { get; private set; }
|
||||
|
||||
/// <summary>+0x08 retail <c>constraint_pos_offset</c> — recomputed every
|
||||
/// <see cref="AdjustOffset"/> as the LENGTH of that tick's step (NOT the
|
||||
/// distance to the anchor — see the ACE correctness note, port-plan §2b);
|
||||
/// the next tick's contact branch compares it against start/max.</summary>
|
||||
public float ConstraintPosOffset { get; private set; }
|
||||
|
||||
/// <summary>+0x0c retail <c>constraint_pos</c> — the leash anchor. Stored
|
||||
/// by <see cref="ConstrainTo"/>, never read by <see cref="AdjustOffset"/>
|
||||
/// (retail + ACE — write-only in this class).</summary>
|
||||
public Position ConstraintPos { get; private set; }
|
||||
|
||||
/// <summary>+0x48 retail <c>constraint_distance_start</c> — the near edge
|
||||
/// of the brake band.</summary>
|
||||
public float ConstraintDistanceStart { get; private set; }
|
||||
|
||||
/// <summary>+0x4c retail <c>constraint_distance_max</c> — the far edge
|
||||
/// (full clamp).</summary>
|
||||
public float ConstraintDistanceMax { get; private set; }
|
||||
|
||||
public ConstraintManager(IPhysicsObjHost host)
|
||||
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ConstraintManager::ConstrainTo</c> (0x00556240). Pin the leash
|
||||
/// anchor and band; initialize <see cref="ConstraintPosOffset"/> to the
|
||||
/// CURRENT distance from anchor to the mover (the leash starts already
|
||||
/// extended to wherever the object is, not at zero).
|
||||
/// </summary>
|
||||
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
|
||||
{
|
||||
IsConstrained = true;
|
||||
ConstraintPos = anchor;
|
||||
ConstraintDistanceStart = startDistance;
|
||||
ConstraintDistanceMax = maxDistance;
|
||||
ConstraintPosOffset = Vector3.Distance(anchor.Frame.Origin, _host.Position.Frame.Origin);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>ConstraintManager::UnConstrain</c> (0x005560c0) —
|
||||
/// clears the constrained flag only (leaves the band/anchor/offset stale
|
||||
/// until the next <see cref="ConstrainTo"/>).</summary>
|
||||
public void UnConstrain() => IsConstrained = false;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ConstraintManager::IsFullyConstrained</c> (0x005560d0):
|
||||
/// <c>constraint_distance_max * 0.9 < constraint_pos_offset</c> — the
|
||||
/// object counts as fully constrained once it has strained past 90 % of the
|
||||
/// max leash. Read by <c>jump_is_allowed</c> to block jumps. Always false
|
||||
/// while the leash is disarmed (acdream never arms it — see class note).
|
||||
/// </summary>
|
||||
public bool IsFullyConstrained()
|
||||
=> ConstraintDistanceMax * 0.9f < ConstraintPosOffset;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ConstraintManager::adjust_offset</c> (0x00556180). The last
|
||||
/// stage of <see cref="PositionManager.AdjustOffset"/>'s chain — clamps the
|
||||
/// ALREADY-composed per-tick <paramref name="offset"/> (interp + sticky)
|
||||
/// while grounded, then records its length for the next tick. No-op unless
|
||||
/// <see cref="IsConstrained"/>. See port-plan §2b.
|
||||
/// </summary>
|
||||
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
|
||||
{
|
||||
_ = quantum; // retail body doesn't use the quantum directly.
|
||||
if (!IsConstrained)
|
||||
return;
|
||||
|
||||
if (_host.InContact) // transient_state & 1 — clamp only while grounded.
|
||||
{
|
||||
if (ConstraintPosOffset < ConstraintDistanceMax)
|
||||
{
|
||||
if (ConstraintPosOffset > ConstraintDistanceStart)
|
||||
{
|
||||
// Linear brake taper: 1.0 just past start → 0.0 at max.
|
||||
float taper = (ConstraintDistanceMax - ConstraintPosOffset)
|
||||
/ (ConstraintDistanceMax - ConstraintDistanceStart);
|
||||
offset.Origin *= taper;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
offset.Origin = Vector3.Zero; // past max — fully pinned.
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional (grounded OR airborne): track this tick's step length.
|
||||
ConstraintPosOffset = offset.Origin.Length();
|
||||
}
|
||||
}
|
||||
100
src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
Normal file
100
src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 seam — the acdream stand-in for retail's <c>CPhysicsObj</c> as seen BY
|
||||
/// its owned managers. Retail's <c>StickyManager</c> / <c>ConstraintManager</c>
|
||||
/// / <c>TargetManager</c> each hold a raw <c>physics_obj</c> pointer and call
|
||||
/// back through it (position/velocity/radius accessors, target-tracking
|
||||
/// registration, the <c>HandleUpdateTarget</c> fan-out) and — for the voyeur
|
||||
/// system — resolve OTHER physics objects via <c>CObjectMaint::GetObjectA</c>
|
||||
/// and drive their <c>add_voyeur</c> / <c>receive_target_update</c> /
|
||||
/// <c>remove_voyeur</c> entry points. This interface is that back-pointer.
|
||||
///
|
||||
/// <para>The App layer implements one host per entity (a remote
|
||||
/// <c>RemoteMotion</c> or the local player), wiring the accessors to the live
|
||||
/// <see cref="PhysicsBody"/> and the <see cref="MoveToManager"/> /
|
||||
/// <see cref="PositionManager"/> / <see cref="TargetManager"/> it owns.
|
||||
/// <see cref="GetObjectA"/> is backed by the App's live entity table
|
||||
/// (<c>_entitiesByServerGuid</c>), giving the voyeur round-trip its
|
||||
/// cross-entity delivery path.</para>
|
||||
/// </summary>
|
||||
public interface IPhysicsObjHost
|
||||
{
|
||||
/// <summary>Retail <c>physics_obj->id</c> — this object's guid.</summary>
|
||||
uint Id { get; }
|
||||
|
||||
/// <summary>Retail <c>physics_obj->m_position</c> — world-space cell +
|
||||
/// frame (acdream seams carry WORLD space; see the MoveToManager binding
|
||||
/// note).</summary>
|
||||
Position Position { get; }
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::get_velocity</c>.</summary>
|
||||
Vector3 Velocity { get; }
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::GetRadius</c> — the mover's cylinder
|
||||
/// radius.</summary>
|
||||
float Radius { get; }
|
||||
|
||||
/// <summary>Retail <c>physics_obj->transient_state & 1</c> — the
|
||||
/// CONTACT bit (ConstraintManager's grounded gate).</summary>
|
||||
bool InContact { get; }
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::get_minterp()->get_max_speed()</c> —
|
||||
/// the mover's max locomotion speed, or <c>null</c> if it has no motion
|
||||
/// interpreter yet (StickyManager falls back to a 15.0 constant).</summary>
|
||||
float? MinterpMaxSpeed { get; }
|
||||
|
||||
/// <summary>Retail <c>Timer::cur_time</c> — the wall/game clock (seconds).
|
||||
/// Drives the sticky 1 s timeout and target 10 s staleness deadlines.</summary>
|
||||
double CurTime { get; }
|
||||
|
||||
/// <summary>Retail <c>PhysicsTimer::curr_time</c> — the physics-tick clock
|
||||
/// (seconds). Drives <c>TargetManager::HandleTargetting</c>'s 0.5 s
|
||||
/// throttle. Retail uses a DIFFERENT clock here than <see cref="CurTime"/>;
|
||||
/// acdream may bind both to the same source.</summary>
|
||||
double PhysicsTimerTime { get; }
|
||||
|
||||
/// <summary>Retail <c>CObjectMaint::GetObjectA(id)</c> — resolve another
|
||||
/// physics object by guid, or <c>null</c> if not currently known/visible.
|
||||
/// The cross-entity seam for the voyeur round-trip and sticky live-target
|
||||
/// resolve.</summary>
|
||||
IPhysicsObjHost? GetObjectA(uint id);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::HandleUpdateTarget</c> — fans a
|
||||
/// <see cref="TargetInfo"/> to this host's <see cref="MoveToManager"/>
|
||||
/// (move-to steering) AND <see cref="PositionManager"/> (sticky follow).
|
||||
/// Called from <see cref="TargetManager.ReceiveUpdate"/> and the timeout
|
||||
/// path.</summary>
|
||||
void HandleUpdateTarget(TargetInfo info);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::interrupt_current_movement</c> →
|
||||
/// <c>MovementManager::CancelMoveTo(0x36)</c>.</summary>
|
||||
void InterruptCurrentMovement();
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::set_target(ctx, objId, radius,
|
||||
/// quantum)</c> → <see cref="TargetManager.SetTarget"/> (lazily creating
|
||||
/// the TargetManager). Called by <c>StickyManager::StickTo</c> and
|
||||
/// <c>MoveToManager</c>'s object-move entry points.</summary>
|
||||
void SetTarget(uint contextId, uint objectId, float radius, double quantum);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::clear_target</c> →
|
||||
/// <see cref="TargetManager.ClearTarget"/>.</summary>
|
||||
void ClearTarget();
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::receive_target_update</c> →
|
||||
/// <see cref="TargetManager.ReceiveUpdate"/>. The inbound side a SENDER's
|
||||
/// <c>SendVoyeurUpdate</c> tail-calls on the watcher.</summary>
|
||||
void ReceiveTargetUpdate(TargetInfo info);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::add_voyeur(id, radius, quantum)</c> →
|
||||
/// <see cref="TargetManager.AddVoyeur"/> (lazily creating the
|
||||
/// TargetManager). Called on the TARGET when a watcher subscribes.</summary>
|
||||
void AddVoyeur(uint watcherId, float radius, double quantum);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::remove_voyeur(id)</c> →
|
||||
/// <see cref="TargetManager.RemoveVoyeur"/>. Called on the TARGET when a
|
||||
/// watcher unsubscribes.</summary>
|
||||
void RemoveVoyeur(uint watcherId);
|
||||
}
|
||||
40
src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs
Normal file
40
src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable stand-in for retail's <c>Frame</c> when it is used as the per-tick
|
||||
/// <b>delta accumulator</b> that <c>PositionManager::adjust_offset</c> and its
|
||||
/// three sub-managers (Interpolation / Sticky / Constraint) write into
|
||||
/// (retail arg2, e.g. <c>StickyManager::adjust_offset</c> 0x00555430,
|
||||
/// <c>ConstraintManager::adjust_offset</c> 0x00556180). acdream's
|
||||
/// <see cref="CellFrame"/> is an immutable record — retail's per-tick math
|
||||
/// mutates <c>m_fOrigin</c> in place across the interp→sticky→constraint chain
|
||||
/// (each sub-manager composes on top of the previous one's write), so the
|
||||
/// accumulator needs a mutable shape.
|
||||
///
|
||||
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
|
||||
/// position delta, in the mover's LOCAL frame after
|
||||
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the
|
||||
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a
|
||||
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/>
|
||||
/// (P5 convention, degrees).</para>
|
||||
/// </summary>
|
||||
public sealed class MotionDeltaFrame
|
||||
{
|
||||
/// <summary>Retail <c>m_fOrigin</c> — the accumulated per-tick position
|
||||
/// delta (mover-local frame).</summary>
|
||||
public Vector3 Origin;
|
||||
|
||||
/// <summary>Retail <c>Frame</c> rotation — carries the
|
||||
/// <c>Frame::set_heading</c> output.</summary>
|
||||
public Quaternion Orientation = Quaternion.Identity;
|
||||
|
||||
/// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary>
|
||||
public float GetHeading() => MoveToMath.GetHeading(Orientation);
|
||||
|
||||
/// <summary>Retail <c>Frame::set_heading(headingDeg)</c> — pure
|
||||
/// yaw-about-Z setter (P5 compass degrees).</summary>
|
||||
public void SetHeading(float headingDeg) =>
|
||||
Orientation = MoveToMath.SetHeading(Orientation, headingDeg);
|
||||
}
|
||||
|
|
@ -1558,10 +1558,21 @@ public sealed class MoveToManager
|
|||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetInfo</c> (acclient.h:31591, struct #3482) — the callback
|
||||
/// payload <see cref="MoveToManager.HandleUpdateTarget"/> consumes. The P4
|
||||
/// TargetTracker adapter (App-side, R4-V4 scope) is the ONLY producer;
|
||||
/// <see cref="MoveToManager"/> itself has no target-tracking machinery of its
|
||||
/// own (TargetManager bodies are R5 — decomp §9f, do-not-invent list).
|
||||
/// payload <see cref="MoveToManager.HandleUpdateTarget"/> consumes AND the wire
|
||||
/// record the R5 <see cref="TargetManager"/> voyeur system exchanges between
|
||||
/// hosts (<c>SendVoyeurUpdate</c> → <c>receive_target_update</c> →
|
||||
/// <see cref="TargetManager.ReceiveUpdate"/>).
|
||||
///
|
||||
/// <para>R5 EXTENDED this from the R4 4-field callback shape to the full retail
|
||||
/// 10-field struct. The extra fields (<see cref="ContextId"/>,
|
||||
/// <see cref="Radius"/>, <see cref="Quantum"/>,
|
||||
/// <see cref="InterpolatedHeading"/>, <see cref="Velocity"/>,
|
||||
/// <see cref="LastUpdateTime"/>) default to zero, so the existing 4-argument
|
||||
/// <c>new TargetInfo(id, status, tp, ip)</c> call sites (MoveToManager tests,
|
||||
/// the AP-79 adapter pre-V2) still compile unchanged.
|
||||
/// <see cref="MoveToManager.HandleUpdateTarget"/> only reads
|
||||
/// <see cref="ObjectId"/>/<see cref="Status"/>/<see cref="InterpolatedPosition"/>;
|
||||
/// the extra fields are consumed by the voyeur system.</para>
|
||||
/// </summary>
|
||||
/// <param name="ObjectId">Retail <c>object_id</c> — matched against
|
||||
/// <see cref="MoveToManager.TopLevelObjectId"/>; a mismatch is silently
|
||||
|
|
@ -1572,11 +1583,30 @@ public sealed class MoveToManager
|
|||
/// <param name="InterpolatedPosition">Retail <c>interpolated_position</c> —
|
||||
/// the smoothed tracking point <see cref="MoveToManager.MoveToObject_Internal"/>
|
||||
/// steers toward.</param>
|
||||
/// <param name="ContextId">Retail <c>context_id</c> — the tracking context
|
||||
/// (0 = the movement context; <c>CPhysicsObj::HandleUpdateTarget</c> only fans
|
||||
/// out context 0).</param>
|
||||
/// <param name="Radius">Retail <c>radius</c> — the voyeur's send-on-move
|
||||
/// threshold (game units).</param>
|
||||
/// <param name="Quantum">Retail <c>quantum</c> — the dead-reckoning lookahead
|
||||
/// horizon (seconds) for <c>GetInterpolatedPosition</c>.</param>
|
||||
/// <param name="InterpolatedHeading">Retail <c>interpolated_heading</c> —
|
||||
/// normalized self→target direction (falls back to +Z when degenerate).</param>
|
||||
/// <param name="Velocity">Retail <c>velocity</c> — the target's velocity at
|
||||
/// send time.</param>
|
||||
/// <param name="LastUpdateTime">Retail <c>last_update_time</c> — receipt
|
||||
/// timestamp (drives the 10 s staleness timeout).</param>
|
||||
public readonly record struct TargetInfo(
|
||||
uint ObjectId,
|
||||
TargetStatus Status,
|
||||
Position TargetPosition,
|
||||
Position InterpolatedPosition);
|
||||
Position InterpolatedPosition,
|
||||
uint ContextId = 0,
|
||||
float Radius = 0f,
|
||||
double Quantum = 0.0,
|
||||
Vector3 InterpolatedHeading = default,
|
||||
Vector3 Velocity = default,
|
||||
double LastUpdateTime = 0.0);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetStatus</c> (acclient.h:7264). Only <see cref="Ok"/> vs
|
||||
|
|
|
|||
|
|
@ -230,6 +230,58 @@ public static class MoveToMath
|
|||
return edgeDist > 0f ? edgeDist : 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Position::cylinder_distance_no_z</c> — the <b>signed</b>
|
||||
/// horizontal (X/Y) edge-to-edge distance between two cylinders:
|
||||
/// <c>centerDist − ownRadius − targetRadius</c>. Unlike
|
||||
/// <see cref="CylinderDistance"/> (the arrival-gate variant, which CLAMPS at
|
||||
/// 0), this variant is NOT clamped — overlapping cylinders report a NEGATIVE
|
||||
/// value. <c>StickyManager::adjust_offset</c> (0x00555430) relies on the
|
||||
/// sign: when the follower is inside the desired 0.3 m stick gap the
|
||||
/// distance goes negative, the per-tick delta inverts, and the mover backs
|
||||
/// off to restore the gap (ACE StickyManager.cs:156).
|
||||
/// </summary>
|
||||
public static float CylinderDistanceNoZ(
|
||||
float ownRadius, Vector3 ownPos, float targetRadius, Vector3 targetPos)
|
||||
{
|
||||
float dx = targetPos.X - ownPos.X;
|
||||
float dy = targetPos.Y - ownPos.Y;
|
||||
float centerDist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
return centerDist - ownRadius - targetRadius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>AC1Legacy::Vector3::normalize_check_small</c> — normalize
|
||||
/// <paramref name="v"/> in place, returning <c>true</c> if the vector was
|
||||
/// too small to normalize (near-zero) and leaving it unchanged in that
|
||||
/// case. Consumed by <c>StickyManager::adjust_offset</c> (don't chase
|
||||
/// jitter when already at the target) and
|
||||
/// <see cref="TargetManager.ReceiveUpdate"/> (interpolated-heading
|
||||
/// fallback). A public shared twin of the private helper in
|
||||
/// <c>ParticleSystem</c>; same 1e-8 near-zero length guard.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> = too small (left unchanged); <c>false</c> =
|
||||
/// normalized.</returns>
|
||||
public static bool NormalizeCheckSmall(ref Vector3 v)
|
||||
{
|
||||
float length = v.Length();
|
||||
if (length < 1e-8f)
|
||||
return true;
|
||||
v /= length;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Position::globaltolocalvec</c> — rotate a WORLD-space vector
|
||||
/// into a frame's LOCAL coordinates by the inverse of the frame's
|
||||
/// orientation. Consumed by <c>StickyManager::adjust_offset</c>
|
||||
/// (0x00555430) to express the self→target offset in the mover's own frame
|
||||
/// before flattening Z and steering. Pure rotation (no translation) —
|
||||
/// <paramref name="worldVec"/> is a direction/offset, not a point.
|
||||
/// </summary>
|
||||
public static Vector3 GlobalToLocalVec(Quaternion frameOrientation, Vector3 worldVec)
|
||||
=> Vector3.Transform(worldVec, Quaternion.Conjugate(frameOrientation));
|
||||
|
||||
/// <summary>
|
||||
/// Landblock-local wire origin → world space (verbatim relocation from
|
||||
/// the deleted <c>RemoteMoveToDriver.OriginToWorld</c>, R4-V4): MoveTo /
|
||||
|
|
|
|||
102
src/AcDream.Core/Physics/Motion/PositionManager.cs
Normal file
102
src/AcDream.Core/Physics/Motion/PositionManager.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — port of retail's <c>PositionManager</c> facade (acclient.h:30952,
|
||||
/// struct #3468; decomp 0x00555160-0x005553d0,
|
||||
/// <c>r5-positionmanager-sticky-decomp.md</c>). A thin fan-out over three
|
||||
/// sub-managers: Interpolation, Sticky, Constraint. Owned 1:1 by the entity's
|
||||
/// <see cref="IPhysicsObjHost"/> (retail <c>CPhysicsObj::position_manager</c>,
|
||||
/// lazily created).
|
||||
///
|
||||
/// <para><b>Interpolation note:</b> retail's <c>adjust_offset</c> chains
|
||||
/// Interpolation → Sticky → Constraint. acdream's interpolation stage lives in
|
||||
/// <see cref="RemoteMotionCombiner"/> (the R5-renamed remote-motion combiner,
|
||||
/// formerly the misnamed <c>Physics.PositionManager</c>) and is NOT chained
|
||||
/// here in V1 — this facade owns only the two R5 targets (Sticky retires TS-39;
|
||||
/// Constraint is structural — see <see cref="ConstraintManager"/>). Folding the
|
||||
/// combiner in as the interp stage is a wiring-slice cleanup.</para>
|
||||
/// </summary>
|
||||
public sealed class PositionManager
|
||||
{
|
||||
private readonly IPhysicsObjHost _host;
|
||||
|
||||
// Lazily created (retail: sticky on first StickTo, constraint on first
|
||||
// ConstrainTo — 0x00555230 / 0x00555280).
|
||||
private StickyManager? _sticky;
|
||||
private ConstraintManager? _constraint;
|
||||
|
||||
public PositionManager(IPhysicsObjHost host)
|
||||
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
|
||||
/// <summary>Exposed for wiring/tests — the lazily-created sub-managers
|
||||
/// (null until first use).</summary>
|
||||
public StickyManager? Sticky => _sticky;
|
||||
public ConstraintManager? Constraint => _constraint;
|
||||
|
||||
/// <summary>Retail <c>PositionManager::StickTo</c> (0x00555230) — lazily
|
||||
/// create the <see cref="StickyManager"/> and begin following
|
||||
/// <paramref name="objectId"/>.</summary>
|
||||
public void StickTo(uint objectId, float radius, float height)
|
||||
{
|
||||
_sticky ??= new StickyManager(_host);
|
||||
_sticky.StickTo(objectId, radius, height);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>PositionManager::UnStick</c> (0x005551e0) — forward
|
||||
/// to the sticky sub-manager if it exists.</summary>
|
||||
public void UnStick() => _sticky?.UnStick();
|
||||
|
||||
/// <summary>Retail <c>PositionManager::GetStickyObjectID</c>
|
||||
/// (0x00555270).</summary>
|
||||
public uint GetStickyObjectId() => _sticky?.TargetId ?? 0u;
|
||||
|
||||
/// <summary>Retail <c>PositionManager::ConstrainTo</c> (0x00555280) —
|
||||
/// lazily create the <see cref="ConstraintManager"/> and arm the leash.
|
||||
/// (Unused in acdream — no arming call site; see
|
||||
/// <see cref="ConstraintManager"/>.)</summary>
|
||||
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
|
||||
{
|
||||
_constraint ??= new ConstraintManager(_host);
|
||||
_constraint.ConstrainTo(anchor, startDistance, maxDistance);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>PositionManager::UnConstrain</c>
|
||||
/// (0x005552b0).</summary>
|
||||
public void UnConstrain() => _constraint?.UnConstrain();
|
||||
|
||||
/// <summary>Retail <c>PositionManager::IsFullyConstrained</c>
|
||||
/// (0x005552c0) — false when no constraint sub-manager exists.</summary>
|
||||
public bool IsFullyConstrained() => _constraint?.IsFullyConstrained() ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>PositionManager::HandleUpdateTarget</c> (0x005553d0) — only
|
||||
/// the sticky sub-manager cares about live target positions (interpolation
|
||||
/// and constraint don't). Fanned out from
|
||||
/// <c>CPhysicsObj::HandleUpdateTarget</c>.
|
||||
/// </summary>
|
||||
public void HandleUpdateTarget(TargetInfo info) => _sticky?.HandleUpdateTarget(info);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>PositionManager::adjust_offset</c> (0x00555190) — chains the
|
||||
/// sub-managers' contributions into the SAME <paramref name="offset"/>
|
||||
/// accumulator, in retail order. Retail runs Interpolation → Sticky →
|
||||
/// Constraint; acdream's interpolation stays in the separate remote-motion
|
||||
/// combiner (see class note), so this chains Sticky → Constraint only.
|
||||
/// Constraint is LAST because it clamps the already-composed displacement.
|
||||
/// </summary>
|
||||
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
|
||||
{
|
||||
_sticky?.AdjustOffset(offset, quantum);
|
||||
_constraint?.AdjustOffset(offset, quantum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>PositionManager::UseTime</c> (0x00555160) — per-tick pump.
|
||||
/// Retail order Interpolation → Constraint → Sticky; acdream runs the two
|
||||
/// owned managers (constraint's UseTime is a retail no-op, so effectively
|
||||
/// just the sticky 1 s watchdog).
|
||||
/// </summary>
|
||||
public void UseTime() => _sticky?.UseTime();
|
||||
}
|
||||
235
src/AcDream.Core/Physics/Motion/StickyManager.cs
Normal file
235
src/AcDream.Core/Physics/Motion/StickyManager.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — verbatim port of retail's <c>StickyManager</c> (acclient.h:31518,
|
||||
/// struct #3466; decomp block 0x00555400-0x00555866,
|
||||
/// <c>r5-positionmanager-sticky-decomp.md</c>). Makes the owning object
|
||||
/// FOLLOW a target object at a bounded gap: each tick
|
||||
/// <see cref="AdjustOffset"/> steers the mover horizontally toward the
|
||||
/// target's live (or last-known) position and turns it to face the target,
|
||||
/// speed- and turn-rate-limited. A 1-second watchdog (<see cref="UseTime"/>)
|
||||
/// drops the stick if no target-position update arrives.
|
||||
///
|
||||
/// <para>Owned by <see cref="PositionManager"/> (lazily created on first
|
||||
/// <see cref="PositionManager.StickTo"/>). Establishes its target-tracking
|
||||
/// subscription through the owning <see cref="IPhysicsObjHost"/>'s
|
||||
/// <c>set_target</c> (→ <see cref="TargetManager"/>); receives the target's
|
||||
/// live position back through <see cref="HandleUpdateTarget"/>, fanned out from
|
||||
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>.</para>
|
||||
///
|
||||
/// <para>The dense x87 back half of retail's <c>adjust_offset</c> is decoded
|
||||
/// against ACE's <c>StickyManager.cs</c> (the two constants
|
||||
/// <see cref="StickyRadius"/>=0.3 and <see cref="StickyTime"/>=1.0 are ACE's,
|
||||
/// verified against the retail mush structure — see the port-plan §2a).</para>
|
||||
/// </summary>
|
||||
public sealed class StickyManager
|
||||
{
|
||||
/// <summary>Retail <c>StickyRadius</c> const (ACE: 0.3f) — the desired
|
||||
/// follow gap subtracted from the cylinder distance.</summary>
|
||||
public const float StickyRadius = 0.3f;
|
||||
|
||||
/// <summary>Retail <c>StickyTime</c> const (ACE: 1.0f) — the one-shot grace
|
||||
/// window: if no target update refreshes the stick within this many
|
||||
/// seconds of <see cref="StickTo"/>, <see cref="UseTime"/> drops it.</summary>
|
||||
public const float StickyTime = 1.0f;
|
||||
|
||||
/// <summary>Retail <c>get_max_speed</c> multiplier for the follow speed
|
||||
/// (ACE: ×5 — the follower catches up faster than a normal walk/run).</summary>
|
||||
private const float FollowSpeedFactor = 5.0f;
|
||||
|
||||
/// <summary>Retail fallback follow speed when no motion interpreter exists
|
||||
/// (ACE: 15.0f, i.e. the <c>MAX_VELOCITY</c> constant the mush loads).</summary>
|
||||
private const float FallbackFollowSpeed = 15.0f;
|
||||
|
||||
private readonly IPhysicsObjHost _host;
|
||||
|
||||
/// <summary>+0x00 retail <c>target_id</c> — the object we are stuck to
|
||||
/// (0 = not stuck).</summary>
|
||||
public uint TargetId { get; private set; }
|
||||
|
||||
/// <summary>+0x04 retail <c>target_radius</c> — the target's cylinder
|
||||
/// radius (from <c>CPartArray::GetRadius</c> of the stuck-to object).</summary>
|
||||
public float TargetRadius { get; private set; }
|
||||
|
||||
/// <summary>+0x08 retail <c>target_position</c> — last-known target
|
||||
/// position (from <see cref="HandleUpdateTarget"/>), used when the live
|
||||
/// <c>GetObjectA</c> resolve fails.</summary>
|
||||
public Position TargetPosition { get; private set; }
|
||||
|
||||
/// <summary>+0x54 retail <c>initialized</c> — false until the first
|
||||
/// <c>Ok</c> target update arrives (gates <see cref="AdjustOffset"/> and
|
||||
/// the <see cref="UseTime"/> timeout).</summary>
|
||||
public bool Initialized { get; private set; }
|
||||
|
||||
/// <summary>+0x58 retail <c>sticky_timeout_time</c> — the wall-clock
|
||||
/// deadline set once at <see cref="StickTo"/> time (now + 1 s).</summary>
|
||||
public double StickyTimeoutTime { get; private set; }
|
||||
|
||||
public StickyManager(IPhysicsObjHost host)
|
||||
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::UnStick</c> (0x00555400). No-op if not stuck;
|
||||
/// otherwise the standard 4-step teardown: clear <see cref="TargetId"/> +
|
||||
/// <see cref="Initialized"/>, tell the host to <c>clear_target</c> (drop
|
||||
/// the voyeur subscription), then <c>interrupt_current_movement</c>.
|
||||
/// </summary>
|
||||
public void UnStick()
|
||||
{
|
||||
if (TargetId == 0)
|
||||
return;
|
||||
|
||||
TargetId = 0;
|
||||
Initialized = false;
|
||||
_host.ClearTarget();
|
||||
_host.InterruptCurrentMovement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::StickTo</c> (0x00555710). Begin following
|
||||
/// <paramref name="objectId"/>. If already stuck, tears the old stick down
|
||||
/// first (same 4-step sequence as <see cref="UnStick"/>). Sets the 1 s
|
||||
/// timeout deadline and registers as a voyeur of the target via the host's
|
||||
/// <c>set_target(context=0, objectId, radius=0.5, quantum=0.5)</c> — the
|
||||
/// live target position then arrives asynchronously through
|
||||
/// <see cref="HandleUpdateTarget"/>.
|
||||
/// </summary>
|
||||
/// <param name="objectId">Retail <c>arg2</c> — target object id.</param>
|
||||
/// <param name="targetRadius">Retail <c>arg3</c> — the target's cylinder
|
||||
/// radius (feeds <see cref="AdjustOffset"/>'s distance math).</param>
|
||||
/// <param name="targetHeight">Retail <c>arg4</c> — accepted for call-shape
|
||||
/// parity but UNUSED in the body (matches retail + ACE; the height feeds
|
||||
/// the caller-side geometry only).</param>
|
||||
public void StickTo(uint objectId, float targetRadius, float targetHeight)
|
||||
{
|
||||
_ = targetHeight; // retail/ACE: arg4 is read nowhere in this body.
|
||||
|
||||
if (TargetId != 0)
|
||||
{
|
||||
// Inlined 4-step teardown of the previous stick (retail 0x00555716).
|
||||
TargetId = 0;
|
||||
Initialized = false;
|
||||
_host.ClearTarget();
|
||||
_host.InterruptCurrentMovement();
|
||||
}
|
||||
|
||||
TargetRadius = targetRadius;
|
||||
TargetId = objectId;
|
||||
Initialized = false;
|
||||
StickyTimeoutTime = _host.CurTime + StickyTime;
|
||||
// set_target(context_id=0, objectId, radius=0.5, quantum=0.5).
|
||||
_host.SetTarget(0, objectId, 0.5f, 0.5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::UseTime</c> (0x00555610). The 1 s watchdog: if
|
||||
/// <c>Timer::cur_time >= sticky_timeout_time</c>, force-unstick (same
|
||||
/// 4-step teardown). The deadline is set once in <see cref="StickTo"/> and
|
||||
/// NOT refreshed by <see cref="HandleUpdateTarget"/> (retail + ACE) — a
|
||||
/// stick survives at most 1 s of wall-clock unless re-issued.
|
||||
/// </summary>
|
||||
public void UseTime()
|
||||
{
|
||||
if (TargetId == 0)
|
||||
return;
|
||||
|
||||
if (_host.CurTime >= StickyTimeoutTime)
|
||||
{
|
||||
TargetId = 0;
|
||||
Initialized = false;
|
||||
_host.ClearTarget();
|
||||
_host.InterruptCurrentMovement();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::HandleUpdateTarget</c> (0x00555780). The
|
||||
/// target-position callback (fanned out from
|
||||
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>).
|
||||
/// Ignores updates whose <see cref="TargetInfo.ObjectId"/> doesn't match
|
||||
/// our <see cref="TargetId"/>. On <see cref="TargetStatus.Ok"/>: cache the
|
||||
/// target position and mark <see cref="Initialized"/>. On any other status
|
||||
/// (lost/exit/teleport): tear the stick down (4-step).
|
||||
/// </summary>
|
||||
public void HandleUpdateTarget(TargetInfo info)
|
||||
{
|
||||
if (info.ObjectId != TargetId)
|
||||
return;
|
||||
|
||||
if (info.Status == TargetStatus.Ok)
|
||||
{
|
||||
Initialized = true;
|
||||
TargetPosition = info.TargetPosition;
|
||||
return;
|
||||
}
|
||||
|
||||
if (TargetId != 0)
|
||||
{
|
||||
TargetId = 0;
|
||||
Initialized = false;
|
||||
_host.ClearTarget();
|
||||
_host.InterruptCurrentMovement();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::adjust_offset</c> (0x00555430). Writes this
|
||||
/// tick's follow steering into the shared <paramref name="offset"/>
|
||||
/// accumulator: a speed-clamped horizontal position delta toward the
|
||||
/// target plus a bounded turn to face it. No-op unless stuck AND
|
||||
/// initialized. See port-plan §2a for the x87-mush decode.
|
||||
/// </summary>
|
||||
/// <param name="offset">The per-tick delta frame
|
||||
/// (<see cref="PositionManager.AdjustOffset"/>'s shared accumulator).</param>
|
||||
/// <param name="quantum">Elapsed time this tick, seconds.</param>
|
||||
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
|
||||
{
|
||||
if (TargetId == 0 || !Initialized)
|
||||
return;
|
||||
|
||||
var self = _host.Position;
|
||||
var target = _host.GetObjectA(TargetId);
|
||||
var targetPos = target != null ? target.Position : TargetPosition;
|
||||
|
||||
// offset = local-frame, Z-flattened vector from self to target.
|
||||
Vector3 worldOffset = targetPos.Frame.Origin - self.Frame.Origin; // Position::get_offset
|
||||
Vector3 local = MoveToMath.GlobalToLocalVec(self.Frame.Orientation, worldOffset);
|
||||
local.Z = 0f;
|
||||
offset.Origin = local;
|
||||
|
||||
// Signed horizontal cylinder distance past the 0.3 m stick gap.
|
||||
float dist = MoveToMath.CylinderDistanceNoZ(
|
||||
_host.Radius, self.Frame.Origin, TargetRadius, targetPos.Frame.Origin) - StickyRadius;
|
||||
|
||||
if (MoveToMath.NormalizeCheckSmall(ref offset.Origin))
|
||||
offset.Origin = Vector3.Zero;
|
||||
|
||||
// Follow speed = 5× own max locomotion speed (catch up), fallback 15.
|
||||
float speed = 0f;
|
||||
float? maxSpeed = _host.MinterpMaxSpeed;
|
||||
if (maxSpeed.HasValue)
|
||||
speed = maxSpeed.Value * FollowSpeedFactor;
|
||||
if (speed < MoveToMath.Epsilon)
|
||||
speed = FallbackFollowSpeed;
|
||||
|
||||
// Don't overshoot: clamp the per-tick step to the remaining (signed)
|
||||
// distance — a negative dist inverts the direction (back off).
|
||||
float delta = speed * (float)quantum;
|
||||
if (delta >= MathF.Abs(dist))
|
||||
delta = dist;
|
||||
offset.Origin *= delta;
|
||||
|
||||
// Bounded turn to face the target (relative heading this tick).
|
||||
float curHeading = MoveToMath.GetHeading(self.Frame.Orientation);
|
||||
float targetHeading = MoveToMath.PositionHeading(self.Frame.Origin, targetPos.Frame.Origin);
|
||||
float heading = targetHeading - curHeading;
|
||||
if (MathF.Abs(heading) < MoveToMath.Epsilon)
|
||||
heading = 0f;
|
||||
if (heading < -MoveToMath.Epsilon)
|
||||
heading += 360f;
|
||||
offset.SetHeading(heading);
|
||||
}
|
||||
}
|
||||
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal file
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — port of retail's <c>TargetManager</c> (acclient.h:31024, struct #3484;
|
||||
/// decomp 0x0051a370-0x0051ad90, <c>r5-targetmanager-decomp.md</c>). A
|
||||
/// peer-to-peer <b>voyeur subscription</b> system with two roles per object:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Watcher</b> (<see cref="TargetInfo"/>): <see cref="SetTarget"/>
|
||||
/// registers this object as a voyeur ON a target; the target's per-tick
|
||||
/// <see cref="HandleTargetting"/> pushes position updates back here via
|
||||
/// <see cref="ReceiveUpdate"/>, which fans them to the owning host's
|
||||
/// MoveToManager + PositionManager (sticky) through
|
||||
/// <see cref="IPhysicsObjHost.HandleUpdateTarget"/>.</item>
|
||||
/// <item><b>Watched</b> (<see cref="VoyeurTable"/>): other objects'
|
||||
/// <see cref="AddVoyeur"/> subscribe to THIS object; each tick
|
||||
/// <see cref="HandleTargetting"/> → <see cref="CheckAndUpdateVoyeur"/> sends a
|
||||
/// dead-reckoned update to any subscriber the object has drifted past the
|
||||
/// subscriber's radius from.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>This REPLACES the AP-79 minimal TargetTracker adapter (GameWindow
|
||||
/// polling the entity table). It is a faithful superset: the same
|
||||
/// move-to tracking (distance > radius → HandleUpdateTarget(Ok)) plus the
|
||||
/// correct sticky, 10 s timeout, and exit/teleport event handling.</para>
|
||||
///
|
||||
/// <para>Owned 1:1 by an <see cref="IPhysicsObjHost"/> (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>, lazily created on first
|
||||
/// <c>set_target</c>/<c>add_voyeur</c>). The two throttle constants
|
||||
/// (<see cref="ThrottleSeconds"/>=0.5, <see cref="StalenessSeconds"/>=10) are
|
||||
/// ACE's, verified against the retail x87 mush — port-plan §2d.</para>
|
||||
/// </summary>
|
||||
public sealed class TargetManager
|
||||
{
|
||||
/// <summary>Retail <c>HandleTargetting</c> per-tick throttle (ACE: 0.5s) —
|
||||
/// the voyeur sweep runs at most this often.</summary>
|
||||
public const double ThrottleSeconds = 0.5;
|
||||
|
||||
/// <summary>Retail target-info staleness timeout (ACE: 10.0s) — an
|
||||
/// Undefined-status target with no update for this long is marked
|
||||
/// TimedOut.</summary>
|
||||
public const double StalenessSeconds = 10.0;
|
||||
|
||||
private readonly IPhysicsObjHost _host;
|
||||
|
||||
private TargetInfo? _targetInfo; // retail target_info (watcher role)
|
||||
private Dictionary<uint, TargettedVoyeurInfo>? _voyeurTable; // retail voyeur_table (watched role)
|
||||
private double _lastUpdateTime; // retail last_update_time (throttle base)
|
||||
|
||||
public TargetManager(IPhysicsObjHost host)
|
||||
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
|
||||
/// <summary>The current watched-target info, or null when tracking
|
||||
/// nothing.</summary>
|
||||
public TargetInfo? TargetInfo => _targetInfo;
|
||||
|
||||
/// <summary>The subscriber table (null until the first
|
||||
/// <see cref="AddVoyeur"/>).</summary>
|
||||
public IReadOnlyDictionary<uint, TargettedVoyeurInfo>? VoyeurTable => _voyeurTable;
|
||||
|
||||
/// <summary>Retail <c>get_target_quantum</c> — the current target's
|
||||
/// quantum, 0 when tracking nothing.</summary>
|
||||
public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0;
|
||||
|
||||
// ── watcher role ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::SetTarget</c> (0x0051ac30). Tear down any
|
||||
/// existing target, then: if <paramref name="objectId"/> is 0, synthesize a
|
||||
/// TimedOut clear-update to the host and leave <see cref="TargetInfo"/>
|
||||
/// null; otherwise create a fresh <see cref="TargetInfo"/> (status
|
||||
/// Undefined) and subscribe as a voyeur ON the target
|
||||
/// (<c>target.add_voyeur(self.id, radius, quantum)</c>). The target's live
|
||||
/// position arrives asynchronously via <see cref="ReceiveUpdate"/>.
|
||||
/// </summary>
|
||||
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
|
||||
{
|
||||
ClearTarget();
|
||||
|
||||
if (objectId == 0)
|
||||
{
|
||||
// Clear/cancel: report a TimedOut update carrying the context,
|
||||
// leave _targetInfo null. (Retail var_10_1 = 6 = TimedOut.)
|
||||
var cleared = new TargetInfo(
|
||||
ObjectId: 0, Status: TargetStatus.TimedOut,
|
||||
TargetPosition: default, InterpolatedPosition: default,
|
||||
ContextId: contextId);
|
||||
_host.HandleUpdateTarget(cleared);
|
||||
return;
|
||||
}
|
||||
|
||||
_targetInfo = new TargetInfo(
|
||||
ObjectId: objectId, Status: TargetStatus.Undefined,
|
||||
TargetPosition: default, InterpolatedPosition: default,
|
||||
ContextId: contextId, Radius: radius, Quantum: quantum,
|
||||
LastUpdateTime: _host.CurTime);
|
||||
|
||||
var target = _host.GetObjectA(objectId);
|
||||
target?.AddVoyeur(_host.Id, radius, quantum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::SetTargetQuantum</c> (0x0051a4a0). Update the
|
||||
/// current target's resend interval and re-register the voyeur subscription
|
||||
/// on the target with the new quantum.
|
||||
/// </summary>
|
||||
public void SetTargetQuantum(double quantum)
|
||||
{
|
||||
if (_targetInfo is not { } ti)
|
||||
return;
|
||||
|
||||
_targetInfo = ti with { Quantum = quantum };
|
||||
var target = _host.GetObjectA(ti.ObjectId);
|
||||
target?.AddVoyeur(_host.Id, ti.Radius, quantum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::ClearTarget</c> (0x0051a7e0). Unsubscribe from
|
||||
/// the current target's voyeur table and drop <see cref="TargetInfo"/>.
|
||||
/// </summary>
|
||||
public void ClearTarget()
|
||||
{
|
||||
if (_targetInfo is not { } ti)
|
||||
return;
|
||||
|
||||
var target = _host.GetObjectA(ti.ObjectId);
|
||||
target?.RemoveVoyeur(_host.Id);
|
||||
_targetInfo = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::ReceiveUpdate</c> (0x0051a930). The inbound
|
||||
/// handler when a target we watch sends us its position (via
|
||||
/// <c>SendVoyeurUpdate</c> → <c>receive_target_update</c>). Ignores updates
|
||||
/// for anything but our current target. Copies the payload, stamps receipt
|
||||
/// time, recomputes the self→target interpolated heading (falls back to +Z
|
||||
/// when degenerate), fans the snapshot to the host, and drops the
|
||||
/// subscription on an ExitWorld status.
|
||||
/// </summary>
|
||||
public void ReceiveUpdate(TargetInfo update)
|
||||
{
|
||||
if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId)
|
||||
return;
|
||||
|
||||
// Copy radius/quantum/positions/velocity/status from the wire; keep our
|
||||
// object_id; stamp receipt time.
|
||||
Vector3 interpHeading = update.InterpolatedPosition.Frame.Origin
|
||||
- _host.Position.Frame.Origin;
|
||||
if (MoveToMath.NormalizeCheckSmall(ref interpHeading))
|
||||
interpHeading = Vector3.UnitZ;
|
||||
|
||||
var updated = ti with
|
||||
{
|
||||
Radius = update.Radius,
|
||||
Quantum = update.Quantum,
|
||||
TargetPosition = update.TargetPosition,
|
||||
InterpolatedPosition = update.InterpolatedPosition,
|
||||
Velocity = update.Velocity,
|
||||
Status = update.Status,
|
||||
InterpolatedHeading = interpHeading,
|
||||
LastUpdateTime = _host.CurTime,
|
||||
};
|
||||
_targetInfo = updated;
|
||||
|
||||
_host.HandleUpdateTarget(updated);
|
||||
|
||||
if (update.Status == TargetStatus.ExitWorld)
|
||||
ClearTarget();
|
||||
}
|
||||
|
||||
// ── watched role ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::AddVoyeur</c> (0x0051a830). A subscriber
|
||||
/// registers to watch this object. If already subscribed, updates its
|
||||
/// radius/quantum in place (no immediate send); otherwise creates the entry
|
||||
/// and pushes an immediate initial snapshot (<c>Ok</c>).
|
||||
/// </summary>
|
||||
public void AddVoyeur(uint watcherId, float radius, double quantum)
|
||||
{
|
||||
_voyeurTable ??= new Dictionary<uint, TargettedVoyeurInfo>();
|
||||
|
||||
if (_voyeurTable.TryGetValue(watcherId, out var existing))
|
||||
{
|
||||
existing.Radius = radius;
|
||||
existing.Quantum = quantum;
|
||||
return;
|
||||
}
|
||||
|
||||
var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum);
|
||||
_voyeurTable[watcherId] = voyeur;
|
||||
SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90).</summary>
|
||||
public bool RemoveVoyeur(uint watcherId)
|
||||
=> _voyeurTable?.Remove(watcherId) ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::HandleTargetting</c> (0x0051aa90). THE per-tick
|
||||
/// driver (no separate <c>UseTime</c>): self-throttled to
|
||||
/// <see cref="ThrottleSeconds"/>, promotes a stale target to TimedOut after
|
||||
/// <see cref="StalenessSeconds"/>, then sweeps every subscriber through
|
||||
/// <see cref="CheckAndUpdateVoyeur"/>.
|
||||
/// </summary>
|
||||
public void HandleTargetting()
|
||||
{
|
||||
if (_host.PhysicsTimerTime - _lastUpdateTime < ThrottleSeconds)
|
||||
return;
|
||||
|
||||
if (_targetInfo is { } ti)
|
||||
{
|
||||
if (ti.Status == TargetStatus.Undefined
|
||||
&& ti.LastUpdateTime + StalenessSeconds < _host.CurTime)
|
||||
{
|
||||
var timedOut = ti with { Status = TargetStatus.TimedOut };
|
||||
_targetInfo = timedOut;
|
||||
_host.HandleUpdateTarget(timedOut);
|
||||
}
|
||||
}
|
||||
|
||||
if (_voyeurTable != null)
|
||||
{
|
||||
foreach (var voyeur in _voyeurTable.Values.ToList())
|
||||
CheckAndUpdateVoyeur(voyeur);
|
||||
}
|
||||
|
||||
_lastUpdateTime = _host.PhysicsTimerTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::CheckAndUpdateVoyeur</c> (0x0051a650). Push an
|
||||
/// update to <paramref name="voyeur"/> only if this object's dead-reckoned
|
||||
/// position has drifted more than the voyeur's radius since the last send.
|
||||
/// </summary>
|
||||
public void CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)
|
||||
{
|
||||
Position newPos = GetInterpolatedPosition(voyeur.Quantum);
|
||||
float drift = Vector3.Distance(
|
||||
newPos.Frame.Origin, voyeur.LastSentPosition.Frame.Origin);
|
||||
if (drift > voyeur.Radius)
|
||||
SendVoyeurUpdate(voyeur, newPos, TargetStatus.Ok);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::GetInterpolatedPosition</c> (0x0051a5e0).
|
||||
/// Dead-reckon this object's position forward by <paramref name="quantum"/>
|
||||
/// seconds using its current velocity.
|
||||
/// </summary>
|
||||
public Position GetInterpolatedPosition(double quantum)
|
||||
{
|
||||
var pos = _host.Position;
|
||||
Vector3 origin = pos.Frame.Origin + _host.Velocity * (float)quantum;
|
||||
return new Position(pos.ObjCellId, origin, pos.Frame.Orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::SendVoyeurUpdate</c> (0x0051a4f0). Record the
|
||||
/// sent position on the voyeur, build a <see cref="TargetInfo"/> carrying
|
||||
/// this object's CURRENT authoritative position + the extrapolated
|
||||
/// <paramref name="pos"/> + velocity + status, and deliver it to the
|
||||
/// subscriber's <see cref="ReceiveUpdate"/> (via its host).
|
||||
/// </summary>
|
||||
public void SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)
|
||||
{
|
||||
voyeur.LastSentPosition = pos;
|
||||
|
||||
var info = new TargetInfo(
|
||||
ObjectId: _host.Id,
|
||||
Status: status,
|
||||
TargetPosition: _host.Position, // current authoritative
|
||||
InterpolatedPosition: pos, // the extrapolated position
|
||||
ContextId: 0,
|
||||
Radius: voyeur.Radius,
|
||||
Quantum: voyeur.Quantum,
|
||||
Velocity: _host.Velocity);
|
||||
|
||||
var voyeurObj = _host.GetObjectA(voyeur.ObjectId);
|
||||
voyeurObj?.ReceiveTargetUpdate(info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::NotifyVoyeurOfEvent</c> (0x0051a6f0). Broadcast
|
||||
/// a discrete status event (e.g. ExitWorld, Teleported) to every subscriber
|
||||
/// with this object's CURRENT position — no distance gate.
|
||||
/// </summary>
|
||||
public void NotifyVoyeurOfEvent(TargetStatus status)
|
||||
{
|
||||
if (_voyeurTable == null)
|
||||
return;
|
||||
|
||||
foreach (var voyeur in _voyeurTable.Values.ToList())
|
||||
SendVoyeurUpdate(voyeur, _host.Position, status);
|
||||
}
|
||||
}
|
||||
36
src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
Normal file
36
src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — port of retail's <c>TargettedVoyeurInfo</c> (acclient.h:52807,
|
||||
/// struct #5801). One entry in a <see cref="TargetManager"/>'s voyeur table:
|
||||
/// a subscriber watching THIS object, the send-on-move <see cref="Radius"/>
|
||||
/// threshold it registered, its dead-reckoning <see cref="Quantum"/>, and the
|
||||
/// <see cref="LastSentPosition"/> already delivered to it (the delta baseline
|
||||
/// <c>CheckAndUpdateVoyeur</c> compares against). Mutable class (retail heap
|
||||
/// record updated in place by <c>AddVoyeur</c>/<c>SendVoyeurUpdate</c>).
|
||||
/// </summary>
|
||||
public sealed class TargettedVoyeurInfo
|
||||
{
|
||||
/// <summary>+0x00 retail <c>object_id</c> — the subscriber's guid.</summary>
|
||||
public uint ObjectId { get; }
|
||||
|
||||
/// <summary>+0x04 retail <c>quantum</c> — the subscriber's dead-reckoning
|
||||
/// lookahead horizon (seconds).</summary>
|
||||
public double Quantum { get; set; }
|
||||
|
||||
/// <summary>+0x10 retail <c>radius</c> — the send-on-move threshold: an
|
||||
/// update is pushed only when the tracked object drifts more than this from
|
||||
/// <see cref="LastSentPosition"/>.</summary>
|
||||
public float Radius { get; set; }
|
||||
|
||||
/// <summary>+0x14 retail <c>last_sent_position</c> — the position last
|
||||
/// delivered to this subscriber (updated by <c>SendVoyeurUpdate</c>).</summary>
|
||||
public Position LastSentPosition { get; set; }
|
||||
|
||||
public TargettedVoyeurInfo(uint objectId, float radius, double quantum)
|
||||
{
|
||||
ObjectId = objectId;
|
||||
Radius = radius;
|
||||
Quantum = quantum;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,8 +17,16 @@ namespace AcDream.Core.Physics;
|
|||
/// active locomotion cycle). We rotate that by the body's orientation
|
||||
/// to get a world-space delta, then add the InterpolationManager's
|
||||
/// world-space correction.
|
||||
///
|
||||
/// <para><b>Renamed R5</b> (was <c>PositionManager</c>): this class is only the
|
||||
/// InterpolationManager-composition portion of retail's
|
||||
/// <c>PositionManager::adjust_offset</c> — NOT the retail PositionManager
|
||||
/// facade. The faithful facade (Sticky/Constraint, owned per entity) is
|
||||
/// <see cref="Motion.PositionManager"/>. The name was freed to remove the
|
||||
/// ambiguity that broke every file importing both
|
||||
/// <c>AcDream.Core.Physics</c> and <c>AcDream.Core.Physics.Motion</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class PositionManager
|
||||
public sealed class RemoteMotionCombiner
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the per-frame world-space delta to add to body.Position.
|
||||
Loading…
Add table
Add a link
Reference in a new issue