acdream/src/AcDream.Runtime/Physics/EntityPhysicsHost.cs
Erik 9e17554ed4 fix(physics): P5 commit 3 - retire TS-35, close #167 (ConstraintManager leash)
PhysicsBody.IsFullyConstrained now reflects real ConstraintManager state
(pushed every tick by the same per-tick pumps commit 2 wired), so
jump_is_allowed's already-ported gate (WeenieError 0x47) actually fires
while an object is rubber-banding hard against a server position
correction, closing the last piece of #167.

Housekeeping:
- Delete register row TS-35 (retired: the write side is no longer stubbed).
- Rewrite the stale doc comments on PhysicsBody.IsFullyConstrained,
  ConstraintManager (class + IsFullyConstrained), PositionManager.ConstrainTo,
  EntityPhysicsHost.PositionManager, and PlayerMovementController.PositionManager
  that described the leash as permanently unarmed/stubbed.
- Close #167 in ISSUES.md citing the research doc and commits e0629145 /
  7719d25b.
- Add an "as-ported" addendum to
  docs/research/2026-07-30-constraint-leash-constants.md naming the actual
  current seam owners (the doc's own open question flagged this as
  implementer-verify-required post-J-slices).
- Update docs/plans/2026-07-29-physics-parity-campaign.md's P5 status and
  CLAUDE.md's Campaign P summary to reflect #167's closure (items #153/#72
  remain open in P5).

Verification: complete solution suite green - 9,978 tests, 5 skips, 0
failures across all 9 test projects (Core.Tests, Runtime.Tests, App.Tests,
Headless.Tests, Core.Net.Tests, Content.Tests, UI.Abstractions.Tests,
Bake.Tests, Cli.Tests).
2026-07-30 12:12:29 +02:00

247 lines
11 KiB
C#

using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Runtime.Physics;
/// <summary>
/// R5-V2 — the Runtime-owned <see cref="IPhysicsObjHost"/> per entity:
/// acdream's
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
/// One is retained per accepted object incarnation on
/// <c>RuntimeEntityRecord</c>, so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs.
///
/// <para>Owns a <see cref="TargetManager"/> (retail
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
/// target seams are repointed here, replacing the AP-79 poll adapter. The
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
/// <see cref="HandleUpdateTarget"/> fan-out are injected by the graphical or
/// no-window host while Runtime retains the host and manager identities.</para>
///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
/// behaviorally identical because the empty facade no-ops until its first
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
/// deliveries to the injected MoveToManager fan FIRST, then the
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
/// @0x00512c1a).</para>
/// </summary>
public sealed class EntityPhysicsHost : IPhysicsObjHost
{
private Func<Position> _getPosition;
private Func<Vector3> _getVelocity;
private Func<float> _getRadius;
private Func<bool> _inContact;
private Func<float?> _minterpMaxSpeed;
private Func<double> _curTime;
private Func<double> _physicsTimerTime;
private Func<uint, IPhysicsObjHost?> _getObjectA;
private Action<TargetInfo> _handleUpdateTarget;
private Action _interruptCurrentMovement;
private readonly TargetManager _targetManager;
public EntityPhysicsHost(
uint id,
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
Id = id;
_getPosition = null!;
_getVelocity = null!;
_getRadius = null!;
_inContact = null!;
_minterpMaxSpeed = null!;
_curTime = null!;
_physicsTimerTime = null!;
_getObjectA = null!;
_handleUpdateTarget = null!;
_interruptCurrentMovement = null!;
Rebind(
getPosition,
getVelocity,
getRadius,
inContact,
minterpMaxSpeed,
curTime,
physicsTimerTime,
getObjectA,
handleUpdateTarget,
interruptCurrentMovement);
_targetManager = new TargetManager(this);
PositionManager = new PositionManager(this);
}
/// <summary>
/// Rebinds the changing CPhysicsObj access seams without replacing this
/// object's retail-lifetime managers. A static/minimal live object can gain
/// a MovementManager later, and the local player controller can be rebuilt,
/// but retail keeps the same CPhysicsObj, TargetManager, and PositionManager
/// for the accepted INSTANCE_TS incarnation. Existing voyeur, sticky, and
/// constraint state therefore survives the upgrade.
/// </summary>
private void Rebind(
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
ArgumentNullException.ThrowIfNull(getPosition);
ArgumentNullException.ThrowIfNull(getVelocity);
ArgumentNullException.ThrowIfNull(getRadius);
ArgumentNullException.ThrowIfNull(inContact);
ArgumentNullException.ThrowIfNull(minterpMaxSpeed);
ArgumentNullException.ThrowIfNull(curTime);
ArgumentNullException.ThrowIfNull(physicsTimerTime);
ArgumentNullException.ThrowIfNull(getObjectA);
ArgumentNullException.ThrowIfNull(handleUpdateTarget);
ArgumentNullException.ThrowIfNull(interruptCurrentMovement);
_getPosition = getPosition;
_getVelocity = getVelocity;
_getRadius = getRadius;
_inContact = inContact;
_minterpMaxSpeed = minterpMaxSpeed;
_curTime = curTime;
_physicsTimerTime = physicsTimerTime;
_getObjectA = getObjectA;
_handleUpdateTarget = handleUpdateTarget;
_interruptCurrentMovement = interruptCurrentMovement;
}
/// <summary>
/// Applies a freshly composed delegate configuration while retaining this
/// host's exact identity and manager instances.
/// </summary>
internal void RebindFrom(EntityPhysicsHost configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
if (configuration.Id != Id)
throw new ArgumentException(
"A physics-host configuration must match the existing host GUID.",
nameof(configuration));
Rebind(
configuration._getPosition,
configuration._getVelocity,
configuration._getRadius,
configuration._inContact,
configuration._minterpMaxSpeed,
configuration._curTime,
configuration._physicsTimerTime,
configuration._getObjectA,
configuration._handleUpdateTarget,
configuration._interruptCurrentMovement);
}
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
public uint Id { get; }
public Position Position => _getPosition();
public Vector3 Velocity => _getVelocity();
public float Radius => _getRadius();
public bool InContact => _inContact();
public float? MinterpMaxSpeed => _minterpMaxSpeed();
public double CurTime => _curTime();
public double PhysicsTimerTime => _physicsTimerTime();
/// <summary>The owned voyeur manager (retail
/// <c>CPhysicsObj::target_manager</c>).</summary>
public TargetManager TargetManager => _targetManager;
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
/// <c>CPhysicsObj::position_manager</c>): sticky follow + the
/// server-position constraint leash (armed as of Campaign P P5, #167).
/// Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers, and
/// <c>LiveEntityNetworkUpdateController</c>'s remote-position
/// <c>ConstrainTo</c> arm.</summary>
public PositionManager PositionManager { get; }
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
_targetManager.GetRelationshipTarget(objectId);
public void HandleUpdateTarget(TargetInfo info)
{
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
// MovementManager (the injected MoveToManager fan) first, then
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
_handleUpdateTarget(info);
PositionManager.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => _interruptCurrentMovement();
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) =>
_targetManager.ReceiveUpdate(info, sender);
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
=> _targetManager.AddVoyeur(watcher, radius, quantum);
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
_targetManager.RemoveVoyeur(watcherId, expectedWatcher);
// ── per-tick driver + Runtime-owned lifecycle ──────────────────────────
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
/// managers' <c>UseTime</c>.</summary>
public void HandleTargetting() => _targetManager.HandleTargetting();
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
/// watcher of this entity that it left the world (they drop the
/// stick/moveto). Called on despawn before the host is removed from the
/// registry.</summary>
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
/// <summary>
/// A Hidden object remains logically alive but is removed from the cell
/// lookup/interaction surface. Retail's DetectionManager sends
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
/// not ported yet, so the current host bridges the same unavailability edge
/// through the existing target fan-out. The non-Ok update tears down watcher
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
/// UnHide re-subscription receives a fresh initial snapshot. The object's
/// own watcher role is deliberately preserved.
/// </summary>
public void NotifyHidden() =>
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
/// (drop this entity's OWN subscription) then
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
/// non-Ok teardown path). Called after a teleport placement.</summary>
public void NotifyTeleported()
{
_targetManager.ClearTarget();
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
}
}