Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
793 lines
32 KiB
C#
793 lines
32 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering.Selection;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Properties;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Ui;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Interaction;
|
|
|
|
internal readonly record struct SelectionCameraSnapshot(
|
|
Matrix4x4 View,
|
|
Matrix4x4 Projection,
|
|
Vector2 Viewport);
|
|
|
|
internal readonly record struct PlayerInteractionPose(uint CellId, Vector3 Position);
|
|
|
|
internal readonly record struct WorldInteractionTarget(
|
|
uint ServerGuid,
|
|
uint LocalEntityId,
|
|
WorldEntity Entity);
|
|
|
|
internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared);
|
|
|
|
internal enum RetailSelectionKind
|
|
{
|
|
Item,
|
|
CompassItem,
|
|
Monster,
|
|
Player,
|
|
UnopenedCorpse,
|
|
}
|
|
|
|
internal enum RetailSelectionDirection
|
|
{
|
|
Closest,
|
|
Previous,
|
|
Next,
|
|
}
|
|
|
|
internal readonly record struct InteractionApproach(
|
|
WorldInteractionTarget Target,
|
|
PlayerInteractionPose Player,
|
|
float UseRadius,
|
|
bool IsCloseRange,
|
|
bool CanCharge,
|
|
float TargetRadius,
|
|
float TargetHeight);
|
|
|
|
internal interface IWorldSelectionQuery
|
|
{
|
|
uint PlayerGuid => 0u;
|
|
uint? PickAtCursor(bool includeSelf);
|
|
uint? PickAt(float mouseX, float mouseY, bool includeSelf);
|
|
void BeginLightingPulse(uint serverGuid);
|
|
bool TryCaptureIdentity(uint serverGuid, out uint localEntityId);
|
|
bool IsCurrent(uint serverGuid, uint localEntityId);
|
|
string Describe(uint serverGuid);
|
|
bool IsCreature(uint serverGuid);
|
|
bool IsHostileMonster(uint serverGuid);
|
|
bool IsAttackableTarget(uint serverGuid);
|
|
ClosestCombatTarget? FindClosestHostileMonster();
|
|
uint? FindSelectionTarget(
|
|
RetailSelectionKind kind,
|
|
RetailSelectionDirection direction,
|
|
uint? anchor,
|
|
bool excludeOwnedByPlayer = false) =>
|
|
kind == RetailSelectionKind.Monster
|
|
&& direction == RetailSelectionDirection.Closest
|
|
? FindClosestHostileMonster()?.ServerGuid
|
|
: null;
|
|
uint? FindLastAttacker() => null;
|
|
bool IsUseable(uint serverGuid);
|
|
bool IsPickupable(uint serverGuid);
|
|
bool IsWieldedByPlayer(uint serverGuid);
|
|
bool IsWieldedPositionState(uint serverGuid);
|
|
bool TryGetApproach(uint serverGuid, out InteractionApproach approach);
|
|
Vector3? GetCombatCameraTargetPoint(uint serverGuid);
|
|
}
|
|
|
|
internal interface IRetainedUiSelectionQuery
|
|
{
|
|
bool ShouldShowHealth(uint serverGuid);
|
|
VividTargetInfo? ResolveVividTargetInfo(uint serverGuid);
|
|
bool IsWithinExternalContainerUseRange(uint serverGuid);
|
|
}
|
|
|
|
internal interface ISelectionViewPlaneSource
|
|
{
|
|
AcDream.App.Rendering.ICamera ApplyViewPlane(
|
|
AcDream.App.Rendering.ICamera camera);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read-only owner for retail world selection and interaction classification.
|
|
/// It consumes the canonical live-entity runtime but never mutates selection,
|
|
/// movement, inventory, or network state.
|
|
/// </summary>
|
|
internal sealed class WorldSelectionQuery
|
|
: IWorldSelectionQuery,
|
|
IRetainedUiSelectionQuery
|
|
{
|
|
private const uint StuckObjectFlag = 0x0004u;
|
|
/// <summary>
|
|
/// ACE's own fallback when a target authors no wire <c>UseRadius</c> at
|
|
/// all (<c>WorldObject_Use.cs:50</c>, <c>useRadius ?? 0.6f</c>) — see
|
|
/// <see cref="GetUseRadius"/>.
|
|
/// </summary>
|
|
private const float DefaultUseRadius = 0.6f;
|
|
private const float AceCanChargeDistance = 7.5f;
|
|
|
|
private const uint SmallItemMask =
|
|
(uint)(ItemType.MeleeWeapon
|
|
| ItemType.Armor
|
|
| ItemType.Clothing
|
|
| ItemType.Jewelry
|
|
| ItemType.Food
|
|
| ItemType.Money
|
|
| ItemType.Misc
|
|
| ItemType.MissileWeapon
|
|
| ItemType.Container
|
|
| ItemType.Gem
|
|
| ItemType.SpellComponents
|
|
| ItemType.Writable
|
|
| ItemType.Key
|
|
| ItemType.Caster);
|
|
|
|
private readonly LiveEntityRuntime _liveEntities;
|
|
private readonly ClientObjectTable _objects;
|
|
private readonly RetailSelectionScene _selectionScene;
|
|
private readonly Func<uint> _playerGuid;
|
|
private readonly Func<SelectionCameraSnapshot> _camera;
|
|
private readonly Func<Vector2> _cursor;
|
|
private readonly Func<PlayerInteractionPose?> _playerPose;
|
|
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder;
|
|
private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere;
|
|
private readonly Func<uint, Matrix4x4?> _childRootPose;
|
|
private readonly Func<uint, bool> _hasOpenedCorpse;
|
|
private readonly Func<CombatMode> _combatMode;
|
|
private readonly Func<uint, bool> _isFellow;
|
|
|
|
public WorldSelectionQuery(
|
|
LiveEntityRuntime liveEntities,
|
|
ClientObjectTable objects,
|
|
RetailSelectionScene selectionScene,
|
|
Func<uint> playerGuid,
|
|
Func<SelectionCameraSnapshot> camera,
|
|
Func<Vector2> cursor,
|
|
Func<PlayerInteractionPose?> playerPose,
|
|
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
|
|
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere,
|
|
Func<uint, Matrix4x4?> childRootPose,
|
|
Func<uint, bool>? hasOpenedCorpse = null,
|
|
Func<CombatMode>? combatMode = null,
|
|
Func<uint, bool>? isFellow = null)
|
|
{
|
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
|
_selectionScene = selectionScene ?? throw new ArgumentNullException(nameof(selectionScene));
|
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
|
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
|
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
|
|
_playerPose = playerPose ?? throw new ArgumentNullException(nameof(playerPose));
|
|
_setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder));
|
|
_selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere));
|
|
_childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose));
|
|
_hasOpenedCorpse = hasOpenedCorpse ?? (_ => false);
|
|
_combatMode = combatMode ?? (() => CombatMode.NonCombat);
|
|
_isFellow = isFellow ?? (_ => false);
|
|
}
|
|
|
|
public uint PlayerGuid => _playerGuid();
|
|
|
|
public uint? PickAtCursor(bool includeSelf)
|
|
{
|
|
Vector2 cursor = _cursor();
|
|
return PickAt(cursor.X, cursor.Y, includeSelf);
|
|
}
|
|
|
|
public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
|
|
{
|
|
SelectionCameraSnapshot camera = _camera();
|
|
RetailSelectionHit? hit = _selectionScene.Pick(
|
|
mouseX,
|
|
mouseY,
|
|
camera.Viewport,
|
|
camera.View,
|
|
camera.Projection,
|
|
includeSelf ? 0u : _playerGuid());
|
|
// Render::GfxObjUnderSelectionRay @ 0x0054C740 records the winning
|
|
// part's own physobj id, so an equipped child resolves to the CHILD's
|
|
// GUID. There is no parent fallback in the retail path: a hit whose
|
|
// identity is no longer current is simply discarded.
|
|
return hit is { } found
|
|
&& _liveEntities.TryGetPickEligibleRecord(
|
|
found.ServerGuid,
|
|
found.LocalEntityId,
|
|
out _)
|
|
? found.ServerGuid
|
|
: null;
|
|
}
|
|
|
|
public void BeginLightingPulse(uint serverGuid)
|
|
{
|
|
if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
|
_selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId);
|
|
}
|
|
|
|
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
|
|
{
|
|
if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
|
{
|
|
localEntityId = target.LocalEntityId;
|
|
return true;
|
|
}
|
|
|
|
localEntityId = 0u;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetInteractionTarget(
|
|
uint serverGuid,
|
|
out WorldInteractionTarget target)
|
|
{
|
|
if (_liveEntities.TryGetPickEligibleRecord(
|
|
serverGuid,
|
|
out LiveEntityRecord record)
|
|
&& record.WorldEntity is { } entity)
|
|
{
|
|
target = new WorldInteractionTarget(serverGuid, entity.Id, entity);
|
|
return true;
|
|
}
|
|
|
|
target = default;
|
|
return false;
|
|
}
|
|
|
|
public bool IsCurrent(WorldInteractionTarget target)
|
|
=> IsCurrent(target.ServerGuid, target.LocalEntityId);
|
|
|
|
public bool IsCurrent(uint serverGuid, uint localEntityId)
|
|
=> _liveEntities.TryGetPickEligibleRecord(serverGuid, localEntityId, out _);
|
|
|
|
public ItemType GetItemType(uint serverGuid)
|
|
=> _objects.Get(serverGuid)?.Type ?? ItemType.None;
|
|
|
|
public string Describe(uint serverGuid)
|
|
{
|
|
string? name = _objects.Get(serverGuid)?.Name;
|
|
return string.IsNullOrWhiteSpace(name) ? $"0x{serverGuid:X8}" : name;
|
|
}
|
|
|
|
public bool IsCreature(uint serverGuid)
|
|
{
|
|
if (serverGuid == _playerGuid()
|
|
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (_liveEntities.TryGetAnimationRuntime(target.LocalEntityId, out var animation)
|
|
&& animation.CurrentMotion == MotionCommand.Dead)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return (GetItemType(serverGuid) & ItemType.Creature) != 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Automatic-acquisition eligibility (register row IA-19): narrowed to
|
|
/// non-player, non-pet, attackable monsters. Backs
|
|
/// <see cref="FindClosestHostileMonster"/> only — never explicit
|
|
/// selection, and never the combat camera (retail gates camera tracking
|
|
/// on <c>ObjectIsAttackable</c>, not this narrower policy — see
|
|
/// <see cref="GetCombatCameraTargetPoint"/>).
|
|
/// </summary>
|
|
public bool IsHostileMonster(uint serverGuid)
|
|
=> IsCreature(serverGuid)
|
|
&& CombatTargetPolicy.IsHostileMonster(
|
|
_playerGuid(),
|
|
_objects.Get(_playerGuid()),
|
|
_objects.Get(serverGuid));
|
|
|
|
/// <summary>
|
|
/// Explicit-target admission for a user-issued attack (#298), and the
|
|
/// combat camera's tracking gate. Retail
|
|
/// <c>ClientCombatSystem::ExecuteAttack @ 0x0056BB70</c> and
|
|
/// <c>UpdateTargetTracking @ 0x0056A950</c> both gate unconditionally on
|
|
/// <c>ObjectIsAttackable @ 0x0056A600</c>, with no player exclusion — a
|
|
/// compatible-PK player is a valid attack target and a valid camera
|
|
/// target. This is deliberately a different, wider predicate than
|
|
/// <see cref="IsHostileMonster"/>, which stays narrowed to monsters for
|
|
/// AUTOMATIC ACQUISITION ONLY per register row IA-19 — IA-19 does not
|
|
/// reach explicit selection or the camera (a presentation gate on an
|
|
/// already-chosen target, not an acquisition path).
|
|
/// </summary>
|
|
public bool IsAttackableTarget(uint serverGuid)
|
|
=> IsCreature(serverGuid)
|
|
&& SelectedObjectHealthPolicy.ObjectIsAttackable(
|
|
_playerGuid(),
|
|
_objects.Get(_playerGuid()),
|
|
serverGuid,
|
|
_objects.Get(serverGuid));
|
|
|
|
public bool ShouldShowHealth(uint serverGuid)
|
|
=> SelectedObjectHealthPolicy.ShouldQueryHealth(
|
|
_playerGuid(),
|
|
_objects.Get(_playerGuid()),
|
|
_objects.Get(serverGuid));
|
|
|
|
public ClosestCombatTarget? FindClosestHostileMonster()
|
|
{
|
|
if (!_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player))
|
|
return null;
|
|
|
|
ClosestCombatTarget? best = null;
|
|
foreach (LiveEntityRecord record in _liveEntities.VisibleRecords)
|
|
{
|
|
uint guid = record.ServerGuid;
|
|
WorldEntity entity = record.WorldEntity!;
|
|
if (!IsHostileMonster(guid))
|
|
continue;
|
|
float distanceSquared = Vector3.DistanceSquared(entity.Position, player.Position);
|
|
if (best is null || distanceSquared < best.Value.DistanceSquared)
|
|
best = new ClosestCombatTarget(guid, distanceSquared);
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of retail <c>CPlayerSystem::SelectNext @ 0x0055F9A0</c>. The
|
|
/// ordering scalar is the retail player-space horizontal distance plus
|
|
/// <c>1.2 * abs(z)</c>; the object id breaks exact-distance ties through
|
|
/// <c>CPlayerSystem::Farther @ 0x0055D830</c>. Previous/next wrap exactly
|
|
/// as the paired calls in <c>CPlayerSystem::OnAction @ 0x00561890</c>.
|
|
/// </summary>
|
|
public uint? FindSelectionTarget(
|
|
RetailSelectionKind kind,
|
|
RetailSelectionDirection direction,
|
|
uint? anchor,
|
|
bool excludeOwnedByPlayer = false)
|
|
{
|
|
uint playerGuid = _playerGuid();
|
|
if (!_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player))
|
|
return null;
|
|
|
|
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
|
|
? RetailRadar.OutdoorRangeMeters
|
|
: RetailRadar.IndoorRangeMeters;
|
|
var candidates = new List<(uint Guid, float Order)>();
|
|
foreach (LiveEntityRecord record in _liveEntities.VisibleRecords)
|
|
{
|
|
uint guid = record.ServerGuid;
|
|
if (guid == 0u
|
|
|| guid == playerGuid
|
|
|| record.WorldEntity is not { } entity
|
|
|| _objects.Get(guid) is not { } obj
|
|
|| (excludeOwnedByPlayer
|
|
&& _objects.IsOwnedByObject(guid, playerGuid)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float order = SelectionOrder(player, entity);
|
|
if (order > radarRadius
|
|
|| !MatchesSelectionKind(kind, guid, obj, record.FinalPhysicsState))
|
|
continue;
|
|
candidates.Add((guid, order));
|
|
}
|
|
|
|
if (candidates.Count == 0)
|
|
return null;
|
|
candidates.Sort(static (left, right) =>
|
|
{
|
|
int distance = left.Order.CompareTo(right.Order);
|
|
return distance != 0 ? distance : left.Guid.CompareTo(right.Guid);
|
|
});
|
|
|
|
if (direction == RetailSelectionDirection.Closest)
|
|
return candidates[0].Guid;
|
|
|
|
(float Order, uint Guid)? anchorKey = null;
|
|
if (anchor is { } anchorGuid
|
|
&& _liveEntities.TryGetWorldEntity(anchorGuid, out WorldEntity anchorEntity))
|
|
{
|
|
anchorKey = (SelectionOrder(player, anchorEntity), anchorGuid);
|
|
}
|
|
|
|
if (anchorKey is null)
|
|
{
|
|
return direction == RetailSelectionDirection.Previous
|
|
? candidates[^1].Guid
|
|
: candidates[0].Guid;
|
|
}
|
|
|
|
if (direction == RetailSelectionDirection.Next)
|
|
{
|
|
foreach ((uint guid, float order) in candidates)
|
|
{
|
|
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) > 0)
|
|
return guid;
|
|
}
|
|
return candidates[0].Guid;
|
|
}
|
|
|
|
for (int i = candidates.Count - 1; i >= 0; i--)
|
|
{
|
|
(uint guid, float order) = candidates[i];
|
|
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) < 0)
|
|
return guid;
|
|
}
|
|
return candidates[^1].Guid;
|
|
}
|
|
|
|
public uint? FindLastAttacker()
|
|
{
|
|
uint playerGuid = _playerGuid();
|
|
uint attacker = 0u;
|
|
if (_objects.Get(playerGuid) is not { } playerObject
|
|
|| !playerObject.Properties.InstanceIds.TryGetValue(
|
|
(uint)PropertyInstanceId.CurrentAttacker,
|
|
out attacker)
|
|
|| attacker == 0u
|
|
|| !_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player)
|
|
|| !_liveEntities.TryGetWorldEntity(attacker, out WorldEntity target))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
|
|
? RetailRadar.OutdoorRangeMeters
|
|
: RetailRadar.IndoorRangeMeters;
|
|
return SelectionOrder(player, target) <= radarRadius ? attacker : null;
|
|
}
|
|
|
|
private bool MatchesSelectionKind(
|
|
RetailSelectionKind kind,
|
|
uint guid,
|
|
ClientObject obj,
|
|
PhysicsStateFlags physicsState)
|
|
{
|
|
bool showableOnRadar = obj.RadarBehavior is { } behavior
|
|
&& RetailRadar.IsShowable((RadarBehavior)behavior, hasPhysicsObject: true);
|
|
PublicWeenieFlags flags = (PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
|
|
bool isFellow = _isFellow(guid);
|
|
bool isCombatCompass = _combatMode() is CombatMode.Melee or CombatMode.Missile;
|
|
bool isSpecialCompassObject = (flags
|
|
& (PublicWeenieFlags.Lifestone
|
|
| PublicWeenieFlags.Portal
|
|
| PublicWeenieFlags.Bindstone)) != 0;
|
|
|
|
// The common tail of CPlayerSystem::SelectNext rejects every object
|
|
// currently inside a container, every cloaked physics object, and a
|
|
// PWD carrying the reserved sign bit, independent of selection kind.
|
|
if (obj.ContainerId != 0u
|
|
|| (physicsState & PhysicsStateFlags.Cloaked) != 0
|
|
|| (((uint)flags & 0x8000_0000u) != 0))
|
|
return false;
|
|
|
|
return kind switch
|
|
{
|
|
RetailSelectionKind.Item =>
|
|
obj.RadarBehavior is null or 0
|
|
|| isSpecialCompassObject,
|
|
RetailSelectionKind.CompassItem =>
|
|
(isSpecialCompassObject || showableOnRadar)
|
|
&& (!isCombatCompass
|
|
|| (IsAttackableTarget(guid)
|
|
&& !isFellow
|
|
&& (flags & PublicWeenieFlags.Vendor) == 0
|
|
&& (physicsState & PhysicsStateFlags.ReportAsEnvironment) == 0)),
|
|
RetailSelectionKind.Monster =>
|
|
showableOnRadar
|
|
&& IsAttackableTarget(guid)
|
|
&& !isFellow
|
|
&& (flags & PublicWeenieFlags.Vendor) == 0,
|
|
RetailSelectionKind.Player =>
|
|
showableOnRadar && (flags & PublicWeenieFlags.Player) != 0,
|
|
RetailSelectionKind.UnopenedCorpse =>
|
|
(flags & PublicWeenieFlags.Corpse) != 0
|
|
&& !_hasOpenedCorpse(guid),
|
|
_ => false,
|
|
};
|
|
}
|
|
|
|
private static float SelectionOrder(WorldEntity player, WorldEntity target)
|
|
{
|
|
Vector3 delta = target.Position - player.Position;
|
|
Vector3 local = Vector3.Transform(delta, Quaternion.Inverse(player.Rotation));
|
|
return MathF.Sqrt(local.X * local.X + local.Y * local.Y)
|
|
+ MathF.Abs(local.Z) * 1.2f;
|
|
}
|
|
|
|
private static int CompareSelectionKey(
|
|
float leftOrder,
|
|
uint leftGuid,
|
|
float rightOrder,
|
|
uint rightGuid)
|
|
{
|
|
int order = leftOrder.CompareTo(rightOrder);
|
|
return order != 0 ? order : leftGuid.CompareTo(rightGuid);
|
|
}
|
|
|
|
private static bool IsOutdoorCell(uint? cellId)
|
|
=> cellId is null || (cellId.Value & 0xFFFFu) < 0x100u;
|
|
|
|
/// <summary>
|
|
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
|
|
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>
|
|
/// on <c>ObjectIsAttackable @ 0x0056A600</c> — the SAME wide predicate as
|
|
/// <c>ExecuteAttack</c>, not the narrower automatic-acquisition policy.
|
|
/// The camera performs no acquisition of its own; it only tracks a
|
|
/// target the player already selected, so routing it through
|
|
/// <see cref="IsAttackableTarget"/> does not touch register row IA-19
|
|
/// (which scopes itself to automatic acquisition).
|
|
/// </summary>
|
|
public Vector3? GetCombatCameraTargetPoint(uint serverGuid)
|
|
=> IsAttackableTarget(serverGuid)
|
|
&& TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
|
|
? target.Entity.Position
|
|
+ Vector3.Transform(new Vector3(0f, 0f, 0.5f), target.Entity.Rotation)
|
|
: null;
|
|
|
|
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid)
|
|
{
|
|
uint playerGuid = _playerGuid();
|
|
ClientObject? target = _objects.Get(serverGuid);
|
|
|
|
// VividTargetIndicator::SetSelected @ 0x004F5CE0 deliberately keeps
|
|
// ACCWeenieObject::selectedID intact while suppressing its own marker
|
|
// for self, player-owned objects, and objects whose current state is
|
|
// IN_CONTAINER. ContainerId is our authoritative placement projection
|
|
// for that retail state. PositionState.WIELDED is a distinct retail
|
|
// state (acclient.h:6802), so a remote character's wielded item keeps
|
|
// its marker while the local player's own wielded item is suppressed
|
|
// by the shared IsOwnedByObject test.
|
|
if (serverGuid == playerGuid
|
|
|| target is null
|
|
|| _objects.IsOwnedByObject(serverGuid, playerGuid)
|
|
|| target.ContainerId != 0u
|
|
|| !(_liveEntities.TryGetSpatiallyProjectedRecord(serverGuid, out _)
|
|
|| _liveEntities.TryGetAttachedProjectedRecord(serverGuid, out _))
|
|
|| !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
uint pwdBits = _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
|
? spawn.ObjectDescriptionFlags ?? 0u
|
|
: 0u;
|
|
return new VividTargetInfo(center, radius, (uint)GetItemType(serverGuid), pwdBits);
|
|
}
|
|
|
|
/// <summary>
|
|
/// SmartBox::GetObjectBoundingBox @ 0x00452E20 pushes the object's OWN
|
|
/// <c>m_position</c> (<c>Render::positionPush(3, &obj->m_position)</c>)
|
|
/// and delegates to CPhysicsObj::GetSelectionSphere @ 0x0050EA40, which
|
|
/// scales the Setup sphere by that object's own part-array scale
|
|
/// (CPartArray::GetSelectionSphere @ 0x00518B80). A 0.1-unit fallback is
|
|
/// used when Setup authored no sphere.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// For an equipped child the object's own m_position is the frame
|
|
/// CPhysicsObj::UpdateChild @ 0x00512D50 composes each tick as
|
|
/// <c>Frame::combine(parent part frame, holding frame)</c>. acdream stores
|
|
/// the PARENT's root in the child projection's Position/Rotation
|
|
/// (EquippedChildRenderController.ApplyParentWorldPose) because the child's
|
|
/// MeshRefs are parent-relative, so the exact equivalent anchor is the
|
|
/// composed child root already published per frame to
|
|
/// EntityEffectPoseRegistry (EquippedChildRenderController.PublishChildPose).
|
|
/// There is no parent fallback: with no published child root the child has
|
|
/// no live composed frame this tick and has no sphere.
|
|
/// </remarks>
|
|
public bool TryGetSelectionSphere(
|
|
uint serverGuid,
|
|
out Vector3 worldCenter,
|
|
out float worldRadius)
|
|
{
|
|
worldCenter = default;
|
|
worldRadius = 0f;
|
|
if (!_liveEntities.TryGetWorldEntity(serverGuid, out WorldEntity entity))
|
|
return false;
|
|
|
|
bool attached =
|
|
_liveEntities.TryGetAttachedProjectedRecord(serverGuid, out _);
|
|
Matrix4x4 childRoot = Matrix4x4.Identity;
|
|
if (attached)
|
|
{
|
|
if (_childRootPose(entity.Id) is not { } published)
|
|
return false;
|
|
childRoot = published;
|
|
}
|
|
|
|
worldCenter = attached ? childRoot.Translation : entity.Position;
|
|
worldRadius = 0.1f;
|
|
if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
|
|| spawn.SetupTableId is not uint setupId
|
|
|| _selectionSphere(setupId) is not { } sphere
|
|
|| sphere.Radius <= 1e-4f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// An attached projection never carries its wire ObjScale on the
|
|
// WorldEntity — the child's scale is baked into its part transforms by
|
|
// EquippedChildAttachment.TryComposePoseInto, leaving RootLocal rigid.
|
|
// The spawn record is therefore the authoritative part-array scale,
|
|
// exactly as EquippedChildRenderController.TryRealize reads it.
|
|
float scale = attached
|
|
? (spawn.ObjScale is { } childScale && childScale > 0f ? childScale : 1f)
|
|
: (entity.Scale > 0f ? entity.Scale : 1f);
|
|
Vector3 localCenter = sphere.Origin * scale;
|
|
worldCenter = attached
|
|
? Vector3.Transform(localCenter, childRoot)
|
|
: entity.Position + Vector3.Transform(localCenter, entity.Rotation);
|
|
worldRadius = sphere.Radius * scale;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>ItemUses::IsUseable @ retail 0x004FCCC0 call family.</summary>
|
|
public bool IsUseable(uint serverGuid)
|
|
{
|
|
if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn))
|
|
return ItemUseability.IsUseable(
|
|
spawn.Useability ?? ItemUseability.Undef);
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's sr_Use branch of
|
|
/// <c>UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @
|
|
/// 0x004E5AD0</c> compares the found object's <c>pwd._wielderID</c> against
|
|
/// <c>SmartBox::player_id</c> at <c>0x004E5BE9</c> and calls
|
|
/// <c>ItemHolder::UseObject</c> only when they differ. Selection and the
|
|
/// click lighting pulse still happen for the player's own wielded item.
|
|
/// </summary>
|
|
public bool IsWieldedByPlayer(uint serverGuid)
|
|
{
|
|
uint playerGuid = _playerGuid();
|
|
return playerGuid != 0u
|
|
&& _objects.Get(serverGuid) is { } item
|
|
&& item.WielderId == playerGuid;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>ACCWeenieObject::DeterminePositionState @ 0x0058BE70</c> resolves
|
|
/// <c>PositionState.WIELDED</c> (acclient.h:6802) as a zero
|
|
/// <c>pwd._containerID</c> with a nonzero <c>pwd._location</c>;
|
|
/// <c>IN_CONTAINER</c> wins when both are set.
|
|
/// <c>ClientObject.CurrentlyEquippedLocation</c> is acdream's projection of
|
|
/// <c>pwd._location</c> (the <c>CurrentWieldedLocation</c> PublicWeenieDesc
|
|
/// field, acclient.h:37175).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Two retail gates read this state. The pickup legality arm at
|
|
/// <c>0x005872B7</c> pairs it with ownership, and
|
|
/// <c>ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680</c> records the
|
|
/// <c>IR_PICK_UP</c> world request only for <c>IN_3D_VIEW</c>, treating
|
|
/// <c>WIELDED</c> as a plain <c>IR_PUT_IN_CONTAINER</c> transfer.
|
|
/// </remarks>
|
|
public bool IsWieldedPositionState(uint serverGuid)
|
|
=> _objects.Get(serverGuid) is { } item
|
|
&& item.ContainerId == 0u
|
|
&& item.CurrentlyEquippedLocation != EquipMask.None;
|
|
|
|
/// <summary>ItemHolder::DetermineUseResult @ 0x00588460 pickup gate.</summary>
|
|
public bool IsPickupable(uint serverGuid)
|
|
{
|
|
if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
|
|| ((spawn.ObjectDescriptionFlags ?? 0u) & StuckObjectFlag) != 0u)
|
|
{
|
|
return false;
|
|
}
|
|
return ((spawn.ItemType ?? 0u) & SmallItemMask) != 0u;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the world-pickup approach. Only retail's
|
|
/// <c>PositionState.IN_3D_VIEW</c> objects have one:
|
|
/// <c>ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680</c> records
|
|
/// <c>IR_PICK_UP</c> for that state alone, and an attached child is by
|
|
/// construction not an independent 3-D object — its bookkeeping
|
|
/// <c>WorldEntity.Position</c> carries the PARENT's composed root
|
|
/// (<c>EquippedChildRenderController.ApplyParentWorldPose</c>), not the
|
|
/// child frame <c>CPhysicsObj::UpdateChild @ 0x00512D50</c> composes.
|
|
/// Refusing the attached case keeps an approach from ever anchoring on a
|
|
/// wielder; wielded items reach their container transfer without one.
|
|
/// </summary>
|
|
public bool TryGetApproach(
|
|
uint serverGuid,
|
|
out InteractionApproach approach)
|
|
{
|
|
if (_playerPose() is not { } player
|
|
|| _liveEntities.TryGetAttachedProjectedRecord(serverGuid, out _)
|
|
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
|
{
|
|
approach = default;
|
|
return false;
|
|
}
|
|
|
|
float useRadius = GetUseRadius(serverGuid);
|
|
float dx = target.Entity.Position.X - player.Position.X;
|
|
float dy = target.Entity.Position.Y - player.Position.Y;
|
|
float distanceSquared = dx * dx + dy * dy;
|
|
(float radius, float height) = _setupCylinder(serverGuid, target.Entity);
|
|
approach = new InteractionApproach(
|
|
target,
|
|
player,
|
|
useRadius,
|
|
distanceSquared <= useRadius * useRadius,
|
|
distanceSquared >= AceCanChargeDistance * AceCanChargeDistance,
|
|
radius,
|
|
height);
|
|
return true;
|
|
}
|
|
|
|
public bool IsWithinExternalContainerUseRange(uint serverGuid)
|
|
{
|
|
if (_playerPose() is not { } playerPose
|
|
|| !_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player)
|
|
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
|
|
|| !_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
|
|| spawn.UseRadius is not > 0f)
|
|
{
|
|
// The server remains authoritative while render projection is absent.
|
|
return true;
|
|
}
|
|
|
|
var playerCylinder = _setupCylinder(_playerGuid(), player);
|
|
var targetCylinder = _setupCylinder(serverGuid, target.Entity);
|
|
return ObjectRangeMath.ObjectsInRange(
|
|
playerPose.Position,
|
|
playerCylinder.Radius,
|
|
playerCylinder.Height,
|
|
target.Entity.Position,
|
|
targetCylinder.Radius,
|
|
targetCylinder.Height,
|
|
spawn.UseRadius.Value,
|
|
useRadii: true,
|
|
ignoreZDelta: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2 gate-finding fix (2026-08-08, grand-gate walk-to-vendor
|
|
/// regression): retail reads the TARGET's own wire-authored
|
|
/// <c>PublicWeenieDesc::_useRadius</c> directly for every
|
|
/// range/approach purpose — <c>CPlayerSystem::RegisterObjectRangeHandler</c>
|
|
/// (<c>pc:195159</c>/<c>203677</c>/<c>210429</c>; <c>203677</c> is
|
|
/// <c>gmVendorUI::OpenVendor</c>'s own range-handler registration,
|
|
/// reading <c>eax->pwd._useRadius</c> for the VENDOR target itself,
|
|
/// the exact NPC kind this bug was found on). ACE's server-side
|
|
/// acceptance test (<c>WorldObject_Use.cs:47-55</c>,
|
|
/// <c>IsWithinUseRadiusOf</c>) reads the SAME wire field:
|
|
/// <c>useRadius ?? 0.6f</c> — no item-type special-casing at all.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>What this replaces.</b> The prior implementation ignored the wire
|
|
/// field entirely and guessed a flat radius from the target's item
|
|
/// type/flags (3m for ANY <see cref="ItemType.Creature"/>, 2m for a
|
|
/// "large object" flag combination, 0.6m otherwise) — an uncited,
|
|
/// retail-incorrect heuristic. For a typical vendor NPC (Creature-typed,
|
|
/// authoring a much tighter UseRadius than 3m in practice) this made
|
|
/// the CLIENT's own local "arrived" test (<c>MoveToManager</c>'s
|
|
/// <see cref="AcDream.Core.Physics.Motion.MoveToManager.GetCurrentDistance"/>
|
|
/// cylinder-distance arrival check, gated on
|
|
/// <see cref="AcDream.Core.Physics.Motion.MovementParameters.DistanceToObject"/>
|
|
/// = this method's return value) satisfied well outside ACE's real
|
|
/// acceptance zone. The player's walk stopped — and the AP-170/G3
|
|
/// arrival-gated Use dispatched — several meters before the player was
|
|
/// ever within ACE's own poll-based <c>WithinUseRadius</c> check
|
|
/// (<c>Player_Move.cs</c>'s <c>CreateMoveToChain</c>), so
|
|
/// <c>ApproachVendor</c> never arrived: the exact "Use lost silently"
|
|
/// defect AP-170 closed, reproduced from a different angle (a too-loose
|
|
/// LOCAL arrival threshold racing ACE's tighter real one, not a missing
|
|
/// arrival gate). The vendor's cosmetic greeting the user observed on
|
|
/// approach is a SEPARATE, distance-only proximity emote independent of
|
|
/// this Use-radius gate — its firing does not imply the player was ever
|
|
/// within ACE's real UseRadius.
|
|
/// </remarks>
|
|
private float GetUseRadius(uint serverGuid)
|
|
{
|
|
bool haveSpawn = _liveEntities.TryGetSnapshot(serverGuid, out var spawn);
|
|
bool fromWire = haveSpawn && spawn.UseRadius is > 0f;
|
|
float radius = fromWire ? spawn.UseRadius!.Value : DefaultUseRadius;
|
|
return radius;
|
|
}
|
|
}
|