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.Selection; 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 readonly record struct InteractionApproach( WorldInteractionTarget Target, PlayerInteractionPose Player, float UseRadius, bool IsCloseRange, bool CanCharge, float TargetRadius, float TargetHeight); internal interface IWorldSelectionQuery { 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(); 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; public WorldSelectionQuery( LiveEntityRuntime liveEntities, ClientObjectTable objects, RetailSelectionScene selectionScene, Func playerGuid, Func camera, Func cursor, Func playerPose, Func setupCylinder, Func selectionSphere, Func childRootPose) { _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)); } 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; } /// /// #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; if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] GetUseRadius guid=0x{serverGuid:X8} radius={radius} " + $"source={(fromWire ? "wire" : "fallback-0.6")}"); } return radius; } }