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); } /// /// 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. /// internal sealed class WorldSelectionQuery : IWorldSelectionQuery, IRetainedUiSelectionQuery { private const uint StuckObjectFlag = 0x0004u; /// /// ACE's own fallback when a target authors no wire UseRadius at /// all (WorldObject_Use.cs:50, useRadius ?? 0.6f) — see /// . /// 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 _playerGuid; private readonly Func _camera; private readonly Func _cursor; private readonly Func _playerPose; private readonly Func _setupCylinder; private readonly Func _selectionSphere; private readonly Func _childRootPose; private readonly Func _hasOpenedCorpse; private readonly Func _combatMode; private readonly Func _isFellow; public WorldSelectionQuery( LiveEntityRuntime liveEntities, ClientObjectTable objects, RetailSelectionScene selectionScene, Func playerGuid, Func camera, Func cursor, Func playerPose, Func setupCylinder, Func selectionSphere, Func childRootPose, Func? hasOpenedCorpse = null, Func? combatMode = null, Func? 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; } /// /// Automatic-acquisition eligibility (register row IA-19): narrowed to /// non-player, non-pet, attackable monsters. Backs /// only — never explicit /// selection, and never the combat camera (retail gates camera tracking /// on ObjectIsAttackable, not this narrower policy — see /// ). /// public bool IsHostileMonster(uint serverGuid) => IsCreature(serverGuid) && CombatTargetPolicy.IsHostileMonster( _playerGuid(), _objects.Get(_playerGuid()), _objects.Get(serverGuid)); /// /// Explicit-target admission for a user-issued attack (#298), and the /// combat camera's tracking gate. Retail /// ClientCombatSystem::ExecuteAttack @ 0x0056BB70 and /// UpdateTargetTracking @ 0x0056A950 both gate unconditionally on /// ObjectIsAttackable @ 0x0056A600, 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 /// , 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). /// 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; } /// /// Port of retail CPlayerSystem::SelectNext @ 0x0055F9A0. The /// ordering scalar is the retail player-space horizontal distance plus /// 1.2 * abs(z); the object id breaks exact-distance ties through /// CPlayerSystem::Farther @ 0x0055D830. Previous/next wrap exactly /// as the paired calls in CPlayerSystem::OnAction @ 0x00561890. /// 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; /// /// #298 follow-up: retail ClientCombatSystem::UpdateTargetTracking /// @ 0x0056A950 (pc:375691-375696) gates CameraSet::TrackTarget /// on ObjectIsAttackable @ 0x0056A600 — the SAME wide predicate as /// ExecuteAttack, 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 /// does not touch register row IA-19 /// (which scopes itself to automatic acquisition). /// 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); } /// /// SmartBox::GetObjectBoundingBox @ 0x00452E20 pushes the object's OWN /// m_position (Render::positionPush(3, &obj->m_position)) /// 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. /// /// /// For an equipped child the object's own m_position is the frame /// CPhysicsObj::UpdateChild @ 0x00512D50 composes each tick as /// Frame::combine(parent part frame, holding frame). 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. /// 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; } /// ItemUses::IsUseable @ retail 0x004FCCC0 call family. public bool IsUseable(uint serverGuid) { if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn)) return ItemUseability.IsUseable( spawn.Useability ?? ItemUseability.Undef); return false; } /// /// Retail's sr_Use branch of /// UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ /// 0x004E5AD0 compares the found object's pwd._wielderID against /// SmartBox::player_id at 0x004E5BE9 and calls /// ItemHolder::UseObject only when they differ. Selection and the /// click lighting pulse still happen for the player's own wielded item. /// public bool IsWieldedByPlayer(uint serverGuid) { uint playerGuid = _playerGuid(); return playerGuid != 0u && _objects.Get(serverGuid) is { } item && item.WielderId == playerGuid; } /// /// ACCWeenieObject::DeterminePositionState @ 0x0058BE70 resolves /// PositionState.WIELDED (acclient.h:6802) as a zero /// pwd._containerID with a nonzero pwd._location; /// IN_CONTAINER wins when both are set. /// ClientObject.CurrentlyEquippedLocation is acdream's projection of /// pwd._location (the CurrentWieldedLocation PublicWeenieDesc /// field, acclient.h:37175). /// /// /// Two retail gates read this state. The pickup legality arm at /// 0x005872B7 pairs it with ownership, and /// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records the /// IR_PICK_UP world request only for IN_3D_VIEW, treating /// WIELDED as a plain IR_PUT_IN_CONTAINER transfer. /// public bool IsWieldedPositionState(uint serverGuid) => _objects.Get(serverGuid) is { } item && item.ContainerId == 0u && item.CurrentlyEquippedLocation != EquipMask.None; /// ItemHolder::DetermineUseResult @ 0x00588460 pickup gate. 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; } /// /// Builds the world-pickup approach. Only retail's /// PositionState.IN_3D_VIEW objects have one: /// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records /// IR_PICK_UP for that state alone, and an attached child is by /// construction not an independent 3-D object — its bookkeeping /// WorldEntity.Position carries the PARENT's composed root /// (EquippedChildRenderController.ApplyParentWorldPose), not the /// child frame CPhysicsObj::UpdateChild @ 0x00512D50 composes. /// Refusing the attached case keeps an approach from ever anchoring on a /// wielder; wielded items reach their container transfer without one. /// 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); } /// /// R2 gate-finding fix (2026-08-08, grand-gate walk-to-vendor /// regression): retail reads the TARGET's own wire-authored /// PublicWeenieDesc::_useRadius directly for every /// range/approach purpose — CPlayerSystem::RegisterObjectRangeHandler /// (pc:195159/203677/210429; 203677 is /// gmVendorUI::OpenVendor's own range-handler registration, /// reading eax->pwd._useRadius for the VENDOR target itself, /// the exact NPC kind this bug was found on). ACE's server-side /// acceptance test (WorldObject_Use.cs:47-55, /// IsWithinUseRadiusOf) reads the SAME wire field: /// useRadius ?? 0.6f — no item-type special-casing at all. /// /// /// What this replaces. The prior implementation ignored the wire /// field entirely and guessed a flat radius from the target's item /// type/flags (3m for ANY , 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 (MoveToManager's /// /// cylinder-distance arrival check, gated on /// /// = 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 WithinUseRadius check /// (Player_Move.cs's CreateMoveToChain), so /// ApproachVendor 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. /// 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; } }