Move local teleport, player-mode, animation, shadow, and sealed-dungeon lifetimes out of GameWindow. Make player-mode entry transactional and isolate approach completions by controller lifetime and approach generation so stale callbacks cannot affect replacements. Co-authored-by: Codex <noreply@openai.com>
344 lines
15 KiB
C#
344 lines
15 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Physics.Motion;
|
|
using AcDream.App.World;
|
|
|
|
namespace AcDream.App.Physics;
|
|
|
|
/// <summary>
|
|
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
|
|
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
|
|
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
|
|
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
|
|
/// and stored on the exact <c>LiveEntityRecord</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 GameWindow so this
|
|
/// class stays free of GameWindow's internals (code-structure rule #1).</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>
|
|
private 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes the first host for an exact live-object incarnation, or
|
|
/// enriches that incarnation's existing minimal host in place. This is the
|
|
/// shared remote/player composition seam; it deliberately never replaces
|
|
/// TargetManager or PositionManager ownership.
|
|
/// </summary>
|
|
internal static EntityPhysicsHost InstallOrRebind(
|
|
LiveEntityRuntime runtime,
|
|
LiveEntityRecord expectedRecord,
|
|
EntityPhysicsHost configuration)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(runtime);
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
ArgumentNullException.ThrowIfNull(configuration);
|
|
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|
|
|| !ReferenceEquals(current, expectedRecord))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host composition.");
|
|
}
|
|
|
|
if (current.PhysicsHost is null)
|
|
{
|
|
runtime.InstallPhysicsHost(current, configuration);
|
|
return configuration;
|
|
}
|
|
|
|
if (current.PhysicsHost is not EntityPhysicsHost existing)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation.");
|
|
}
|
|
|
|
existing.RebindFrom(configuration);
|
|
return existing;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates an exact incarnation and returns the manager-stable host that
|
|
/// a later <see cref="InstallOrRebind"/> will publish, without changing any
|
|
/// live delegates. Player-mode construction uses this preparation edge so
|
|
/// DAT/physics/camera failures cannot expose a half-rebound host.
|
|
/// </summary>
|
|
internal static EntityPhysicsHost SelectStableHostWithoutRebind(
|
|
LiveEntityRuntime runtime,
|
|
LiveEntityRecord expectedRecord,
|
|
EntityPhysicsHost configuration)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(runtime);
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
ArgumentNullException.ThrowIfNull(configuration);
|
|
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|
|
|| !ReferenceEquals(current, expectedRecord))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host preparation.");
|
|
}
|
|
|
|
return current.PhysicsHost switch
|
|
{
|
|
null => configuration,
|
|
EntityPhysicsHost existing => existing,
|
|
_ => throw new InvalidOperationException(
|
|
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the position-only CPhysicsObj facade used before an animated or
|
|
/// player movement owner exists. The delegates capture the exact live
|
|
/// record rather than the visible-world projection, so exit_world
|
|
/// notifications retain the object's last cell and frame after the picker
|
|
/// view has already withdrawn it.
|
|
/// </summary>
|
|
internal static EntityPhysicsHost CreateMinimal(
|
|
LiveEntityRecord record,
|
|
Func<uint, IPhysicsObjHost?> resolve,
|
|
Func<double> now)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
ArgumentNullException.ThrowIfNull(resolve);
|
|
ArgumentNullException.ThrowIfNull(now);
|
|
return new EntityPhysicsHost(
|
|
record.ServerGuid,
|
|
getPosition: () => new Position(
|
|
record.FullCellId,
|
|
record.WorldEntity?.Position ?? Vector3.Zero,
|
|
record.WorldEntity?.Rotation ?? Quaternion.Identity),
|
|
getVelocity: () => Vector3.Zero,
|
|
getRadius: () => 0f,
|
|
inContact: () => true,
|
|
minterpMaxSpeed: () => null,
|
|
curTime: now,
|
|
physicsTimerTime: now,
|
|
getObjectA: resolve,
|
|
handleUpdateTarget: _ => { },
|
|
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 + (unarmed)
|
|
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
|
|
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
|
|
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</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 + lifecycle (called by GameWindow) ─────────────────
|
|
|
|
/// <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 App 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);
|
|
}
|
|
}
|