diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs
new file mode 100644
index 00000000..f6a5cae1
--- /dev/null
+++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs
@@ -0,0 +1,348 @@
+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);
+
+///
+/// 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
+{
+ private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
+ private const uint StuckObjectFlag = 0x0004u;
+ private const float DefaultUseRadius = 0.6f;
+ private const float CreatureUseRadius = 3f;
+ private const float LargeObjectUseRadius = 2f;
+ 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;
+
+ public WorldSelectionQuery(
+ LiveEntityRuntime liveEntities,
+ ClientObjectTable objects,
+ RetailSelectionScene selectionScene,
+ Func playerGuid,
+ Func camera,
+ Func cursor,
+ Func playerPose,
+ Func setupCylinder,
+ Func selectionSphere)
+ {
+ _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));
+ }
+
+ 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());
+ return hit is { } found
+ && _liveEntities.TryGetInteractionEligibleRecord(
+ 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 TryGetInteractionTarget(
+ uint serverGuid,
+ out WorldInteractionTarget target)
+ {
+ if (_liveEntities.TryGetInteractionEligibleRecord(
+ 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.TryGetInteractionEligibleRecord(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;
+ }
+
+ public bool IsHostileMonster(uint serverGuid)
+ => IsCreature(serverGuid)
+ && CombatTargetPolicy.IsHostileMonster(
+ _playerGuid(),
+ _objects.Get(_playerGuid()),
+ _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 ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities)
+ {
+ 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;
+ }
+
+ public Vector3? GetCombatCameraTargetPoint(uint serverGuid)
+ => IsHostileMonster(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)
+ {
+ if (!_liveEntities.TryGetWorldEntity(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 delegates to
+ /// CPhysicsObj::GetSelectionSphere @ 0x0050EA40 and uses a 0.1-unit
+ /// fallback when Setup authored 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;
+
+ worldCenter = 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;
+ }
+
+ float scale = entity.Scale > 0f ? entity.Scale : 1f;
+ Vector3 localCenter = sphere.Origin * scale;
+ worldCenter = entity.Position + Vector3.Transform(localCenter, entity.Rotation);
+ worldRadius = sphere.Radius * scale;
+ return true;
+ }
+
+ /// ItemUses::IsUseable @ retail 0x00566A20 call family.
+ public bool IsUseable(uint serverGuid)
+ {
+ if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn))
+ {
+ if (spawn.Useability is uint useability)
+ return useability is not 0u and not 1u;
+ if (((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u)
+ return true;
+ }
+ return (GetItemType(serverGuid) & ItemType.Creature) != 0;
+ }
+
+ /// 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;
+ }
+
+ public bool TryGetApproach(
+ uint serverGuid,
+ out InteractionApproach approach)
+ {
+ if (_playerPose() is not { } player
+ || !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);
+ }
+
+ private float GetUseRadius(uint serverGuid)
+ {
+ if ((GetItemType(serverGuid) & ItemType.Creature) != 0)
+ return CreatureUseRadius;
+ return _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
+ && ((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u
+ ? LargeObjectUseRadius
+ : DefaultUseRadius;
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index dd267485..6c4996a1 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -44,6 +44,7 @@ public sealed class GameWindow : IDisposable
private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
+ private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
/// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup — missing bindless throws
/// in OnLoad.
@@ -304,6 +305,8 @@ public sealed class GameWindow : IDisposable
{
public required AcDream.Core.World.WorldEntity Entity;
AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => Entity;
+ uint AcDream.App.World.ILiveEntityAnimationRuntime.CurrentMotion =>
+ Sequencer?.CurrentMotion ?? 0u;
public required DatReaderWriter.DBObjs.Setup Setup;
public required DatReaderWriter.DBObjs.Animation Animation;
public required int LowFrame;
@@ -2743,6 +2746,40 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
_dats!, _datLock)),
_retailAlphaQueue);
+ _worldSelectionQuery ??= new AcDream.App.Interaction.WorldSelectionQuery(
+ _liveEntities,
+ Objects,
+ _retailSelectionScene,
+ () => _playerServerGuid,
+ () =>
+ {
+ var camera = GetSelectionCamera();
+ return new AcDream.App.Interaction.SelectionCameraSnapshot(
+ camera.View,
+ camera.Projection,
+ camera.Viewport);
+ },
+ () => new System.Numerics.Vector2(_lastMouseX, _lastMouseY),
+ () => _playerController is { } player
+ ? new AcDream.App.Interaction.PlayerInteractionPose(
+ player.CellId,
+ player.Position)
+ : null,
+ GetSetupCylinder,
+ setupId =>
+ {
+ if (_dats is null)
+ return null;
+ lock (_datLock)
+ {
+ if (!_dats.TryGet(setupId, out var setup)
+ || setup.SelectionSphere is not { } sphere)
+ {
+ return null;
+ }
+ return (sphere.Origin, sphere.Radius);
+ }
+ });
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@@ -13740,14 +13777,10 @@ public sealed class GameWindow : IDisposable
{
if (!_persistedGameplay.ViewCombatTarget
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
- || _selection.SelectedObjectId is not uint selected
- || !IsLiveHostileMonsterTarget(selected)
- || !_visibleEntitiesByServerGuid.TryGetValue(selected, out var target))
+ || _selection.SelectedObjectId is not uint selected)
return null;
- return target.Position
- + System.Numerics.Vector3.Transform(
- new System.Numerics.Vector3(0f, 0f, 0.5f), target.Rotation);
+ return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected);
}
// ============================================================
@@ -13765,45 +13798,13 @@ public sealed class GameWindow : IDisposable
/// pick the local player while plain selection excludes it.
///
private uint? PickWorldGuidAtCursor(bool includeSelf)
- => PickWorldGuidAt(_lastMouseX, _lastMouseY, includeSelf);
+ => _worldSelectionQuery?.PickAtCursor(includeSelf);
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
- {
- if (_retailSelectionScene is null
- || _liveEntities is null
- || _window is null)
- return null;
- var camera = GetSelectionCamera();
- AcDream.Core.Selection.RetailSelectionHit? hit = _retailSelectionScene.Pick(
- mouseX,
- mouseY,
- camera.Viewport,
- camera.View,
- camera.Projection,
- includeSelf ? 0u : _playerServerGuid);
- if (hit is not { } found
- || !_liveEntities.TryGetInteractionEligibleRecord(
- found.ServerGuid,
- found.LocalEntityId,
- out _))
- {
- return null;
- }
-
- return found.ServerGuid;
- }
+ => _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf);
private void BeginSelectionLightingPulse(uint serverGuid)
- {
- if (_retailSelectionScene is null
- || _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord record) != true
- || record.LocalEntityId is not { } localEntityId)
- {
- return;
- }
-
- _retailSelectionScene.BeginLightingPulse(serverGuid, localEntityId);
- }
+ => _worldSelectionQuery?.BeginLightingPulse(serverGuid);
private void PickAndStoreSelection(bool useImmediately)
{
@@ -13918,9 +13919,9 @@ public sealed class GameWindow : IDisposable
// Close-range deferral fires the wire packet ONCE on
// MoveToComplete(None) (turn-first done), not a retry of an
// earlier failed send. No re-send path.
- if (_liveEntities?.TryGetInteractionEligibleRecord(guid, out LiveEntityRecord useTarget)
- != true
- || useTarget.LocalEntityId is not { } useTargetLocalId)
+ if (_worldSelectionQuery?.TryGetInteractionTarget(
+ guid,
+ out AcDream.App.Interaction.WorldInteractionTarget useTarget) != true)
{
return;
}
@@ -13934,7 +13935,7 @@ public sealed class GameWindow : IDisposable
// will fire it after rotation completes.
_pendingPostArrivalAction = new PendingPostArrivalAction(
guid,
- useTargetLocalId,
+ useTarget.LocalEntityId,
IsPickup: false);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
@@ -14014,10 +14015,9 @@ public sealed class GameWindow : IDisposable
// overlay reports arrival.
//
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
- if (_liveEntities?.TryGetInteractionEligibleRecord(
+ if (_worldSelectionQuery?.TryGetInteractionTarget(
itemGuid,
- out LiveEntityRecord pickupTarget) != true
- || pickupTarget.LocalEntityId is not { } pickupTargetLocalId)
+ out AcDream.App.Interaction.WorldInteractionTarget pickupTarget) != true)
{
return;
}
@@ -14029,7 +14029,7 @@ public sealed class GameWindow : IDisposable
{
_pendingPostArrivalAction = new PendingPostArrivalAction(
itemGuid,
- pickupTargetLocalId,
+ pickupTarget.LocalEntityId,
IsPickup: true,
destinationContainerId,
placement);
@@ -14075,10 +14075,9 @@ public sealed class GameWindow : IDisposable
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return;
- if (_liveEntities?.TryGetInteractionEligibleRecord(
+ if (_worldSelectionQuery?.IsCurrent(
pending.Guid,
- pending.LocalEntityId,
- out _) != true)
+ pending.LocalEntityId) != true)
{
return;
}
@@ -14132,89 +14131,21 @@ public sealed class GameWindow : IDisposable
/// until our local rotation overlay reports alignment, then fire.
///
private bool IsCloseRangeTarget(uint targetGuid)
- {
- if (_playerController is null) return false;
- if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
- return false;
-
- // Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic.
- float useRadius = 0.6f;
- if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0)
- {
- useRadius = 3.0f;
- }
- else if (LastSpawns.TryGetValue(targetGuid, out var spawn)
- && spawn.ObjectDescriptionFlags is { } odf)
- {
- const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
- if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
- }
-
- var bodyPos = _playerController.Position;
- float dx = entity.Position.X - bodyPos.X;
- float dy = entity.Position.Y - bodyPos.Y;
- float distSq = dx * dx + dy * dy;
- return distSq <= useRadius * useRadius;
- }
+ => _worldSelectionQuery?.TryGetApproach(targetGuid, out var approach) == true
+ && approach.IsCloseRange;
private bool IsWithinExternalContainerUseRange(uint targetGuid)
- {
- if (_playerController is null
- || !_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)
- || !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
- {
- // The server remains authoritative for forced close. An entity can be
- // temporarily absent from the visible-cell projection while its external
- // view is still valid; do not manufacture a range exit from missing render state.
- return true;
- }
-
- if (!LastSpawns.TryGetValue(targetGuid, out var spawn)
- || spawn.UseRadius is not > 0f)
- return true;
-
- // gmExternalContainerUI::SetGroundObject @ 0x004CBBD0 registers the
- // root's UseRadius with useRadii=true and ignoreZDelta=false.
- // CPlayerSystem::CalculateObjectRangeChecks @ 0x0055F360 then calls
- // ACCWeenieObject::ObjectsInRange @ 0x0058C1A0, whose use-radii branch
- // compares the gap between both physics cylinders, not their centers.
- var playerCylinder = GetSetupCylinder(_playerServerGuid, playerEntity);
- var targetCylinder = GetSetupCylinder(targetGuid, entity);
- return AcDream.Core.Physics.ObjectRangeMath.ObjectsInRange(
- _playerController.Position,
- playerCylinder.Radius,
- playerCylinder.Height,
- entity.Position,
- targetCylinder.Radius,
- targetCylinder.Height,
- spawn.UseRadius.Value,
- useRadii: true,
- ignoreZDelta: false);
- }
+ => _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
private void InstallSpeculativeTurnToTarget(uint targetGuid)
{
- if (_playerController is not { } pc || pc.MoveTo is null) return;
- if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
+ if (_playerController is not { } pc
+ || pc.MoveTo is null
+ || _worldSelectionQuery?.TryGetApproach(
+ targetGuid,
+ out AcDream.App.Interaction.InteractionApproach approach) != true)
return;
- // Per-type use radius — same heuristic as the picker's
- // radiusForGuid callback (register AP-23, re-anchored here by
- // R4-V5; survives because ACE's close-branch broadcasts nothing
- // actionable).
- float useRadius = 0.6f;
- if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0)
- {
- useRadius = 3.0f;
- }
- else if (LastSpawns.TryGetValue(targetGuid, out var spawn)
- && spawn.ObjectDescriptionFlags is { } odf)
- {
- // BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE
- const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
- if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
- }
-
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
// from local distance so the speculative moveto uses the
// same walk/run as the wire-triggered overwrite that arrives
@@ -14224,13 +14155,6 @@ public sealed class GameWindow : IDisposable
// the speculative install doesn't flip walk↔run when ACE's
// MoveToObject broadcast overwrites it (PerformMovement
// cancels + restarts — retail-consistent re-target).
- const float AceCanChargeDistance = 7.5f;
- var bodyPos = _playerController.Position;
- float ddx = entity.Position.X - bodyPos.X;
- float ddy = entity.Position.Y - bodyPos.Y;
- float distToTarget = MathF.Sqrt(ddx * ddx + ddy * ddy);
- bool speculativeCanCharge = distToTarget >= AceCanChargeDistance;
-
// R4-V5: retail's client-initiated use flow issues TurnToObject /
// MoveToObject through the SAME manager the wire path uses (decomp
// §9a/§9b callers) — in-range targets get a pure turn-to-face;
@@ -14242,18 +14166,18 @@ public sealed class GameWindow : IDisposable
// are non-default.
var p = new AcDream.Core.Physics.Motion.MovementParameters
{
- DistanceToObject = useRadius,
- CanCharge = speculativeCanCharge,
+ DistanceToObject = approach.UseRadius,
+ CanCharge = approach.CanCharge,
};
var ms = new AcDream.Core.Physics.MovementStruct
{
ObjectId = targetGuid,
TopLevelId = targetGuid,
Pos = new AcDream.Core.Physics.Position(
- _playerController.CellId, entity.Position,
+ approach.Player.CellId, approach.Target.Entity.Position,
System.Numerics.Quaternion.Identity),
Params = p,
- Type = IsCloseRangeTarget(targetGuid)
+ Type = approach.IsCloseRange
? AcDream.Core.Physics.MovementType.TurnToObject
: AcDream.Core.Physics.MovementType.MoveToObject,
};
@@ -14265,7 +14189,7 @@ public sealed class GameWindow : IDisposable
// arrival hybrid (centerDist − playerRadius ≤ useRadius) — the
// auto-walk stopped ~one player-radius farther out than the AP-23
// constants intended, and the two MoveToObject sites disagreed.
- (ms.Radius, ms.Height) = GetSetupCylinder(targetGuid, entity);
+ (ms.Radius, ms.Height) = (approach.TargetRadius, approach.TargetHeight);
// Part of this install's AP-23 adaptation: store the autonomy flag
// exactly as the wire mt-6 this install anticipates would (the P1
// unpack store, IsAutonomous=false) — without it the per-tick
@@ -14281,25 +14205,9 @@ public sealed class GameWindow : IDisposable
private uint? SelectClosestCombatTarget(bool showToast)
{
- if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
- return null;
-
- uint? bestGuid = null;
- float bestDistanceSq = float.PositiveInfinity;
- foreach (var (guid, entity) in _visibleEntitiesByServerGuid)
- {
- if (!IsLiveHostileMonsterTarget(guid))
- continue;
-
- float distanceSq = System.Numerics.Vector3.DistanceSquared(
- entity.Position,
- playerEntity.Position);
- if (distanceSq >= bestDistanceSq)
- continue;
-
- bestDistanceSq = distanceSq;
- bestGuid = guid;
- }
+ AcDream.App.Interaction.ClosestCombatTarget? closest =
+ _worldSelectionQuery?.FindClosestHostileMonster();
+ uint? bestGuid = closest?.ServerGuid;
if (bestGuid is { } selection)
_selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard);
@@ -14308,7 +14216,7 @@ public sealed class GameWindow : IDisposable
if (bestGuid is { } selected)
{
string label = DescribeLiveEntity(selected);
- float distance = MathF.Sqrt(bestDistanceSq);
+ float distance = MathF.Sqrt(closest!.Value.DistanceSquared);
Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
if (showToast)
_debugVm?.AddToast($"Target {label}");
@@ -14323,30 +14231,10 @@ public sealed class GameWindow : IDisposable
}
private bool IsLiveCreatureTarget(uint guid)
- {
- if (guid == _playerServerGuid)
- return false;
- if (!_visibleEntitiesByServerGuid.ContainsKey(guid))
- return false;
-
- if (_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity)
- && _animatedEntities.TryGetValue(entity.Id, out var animated)
- && animated.Sequencer?.CurrentMotion == AcDream.Core.Physics.MotionCommand.Dead)
- return false;
-
- return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;
- }
+ => _worldSelectionQuery?.IsCreature(guid) == true;
private bool IsLiveHostileMonsterTarget(uint guid)
- {
- if (!IsLiveCreatureTarget(guid))
- return false;
-
- return AcDream.Core.Combat.CombatTargetPolicy.IsHostileMonster(
- _playerServerGuid,
- Objects.Get(_playerServerGuid),
- Objects.Get(guid));
- }
+ => _worldSelectionQuery?.IsHostileMonster(guid) == true;
///
/// True if the selected-object strip should show a Health meter for .
@@ -14355,10 +14243,7 @@ public sealed class GameWindow : IDisposable
/// gmToolbarUI::HandleSelectionChanged @ 0x004BF380.
///
private bool IsHealthBarTarget(uint guid)
- => AcDream.Core.Combat.SelectedObjectHealthPolicy.ShouldQueryHealth(
- _playerServerGuid,
- Objects.Get(_playerServerGuid),
- Objects.Get(guid));
+ => _worldSelectionQuery?.ShouldShowHealth(guid) == true;
private (System.Numerics.Matrix4x4 View,
System.Numerics.Matrix4x4 Projection,
@@ -14374,20 +14259,7 @@ public sealed class GameWindow : IDisposable
}
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
- {
- if (!_entitiesByServerGuid.ContainsKey(guid)
- || !TryGetEntitySelectionSphere(guid, out var center, out float radius))
- return null;
-
- uint pwdBits = LastSpawns.TryGetValue(guid, out var spawn)
- ? spawn.ObjectDescriptionFlags ?? 0u
- : 0u;
- return new AcDream.App.UI.Layout.VividTargetInfo(
- center,
- radius,
- (uint)LiveItemType(guid),
- pwdBits);
- }
+ => _worldSelectionQuery?.ResolveVividTargetInfo(guid);
///
@@ -14420,45 +14292,11 @@ public sealed class GameWindow : IDisposable
out System.Numerics.Vector3 worldCenter,
out float worldRadius)
{
+ if (_worldSelectionQuery is { } query)
+ return query.TryGetSelectionSphere(guid, out worldCenter, out worldRadius);
worldCenter = default;
worldRadius = 0f;
-
- if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
-
- // SmartBox::GetObjectBoundingBox @ 0x00452E20 installs a 0.1-unit
- // origin sphere when CPhysicsObj::GetSelectionSphere has no authored
- // Setup sphere. Preserve that presentation for every materialized
- // object rather than dropping the marker entirely.
- worldCenter = entity.Position;
- worldRadius = 0.1f;
- if (!LastSpawns.TryGetValue(guid, out var spawn)
- || spawn.SetupTableId is not uint setupId
- || _dats is null
- || !_dats.TryGet(setupId, out var setup))
- return true;
-
- // DAT Setup carries `SelectionSphere` (Origin + Radius). A zero
- // radius means the Setup did not author one, so retain the retail
- // 0.1-unit fallback installed above.
- var sel = setup.SelectionSphere;
- if (sel is null || sel.Radius <= 1e-4f) return true;
-
- // Retail GetSelectionSphere applies part-array scale to the
- // sphere center (component-wise) and to the radius (Z-scale
- // only). For uniform entity scale these coincide.
- float scale = entity.Scale > 0f ? entity.Scale : 1f;
- var localCenter = new System.Numerics.Vector3(
- sel.Origin.X * scale,
- sel.Origin.Y * scale,
- sel.Origin.Z * scale);
-
- // Setup-local center → world. Entity rotation applies; entity
- // position is the world origin of the setup.
- var rot = System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation);
- var rotated = System.Numerics.Vector3.Transform(localCenter, rot);
- worldCenter = entity.Position + rotated;
- worldRadius = sel.Radius * scale;
- return true;
+ return false;
}
///
@@ -14490,90 +14328,7 @@ public sealed class GameWindow : IDisposable
///
///
private bool IsUseableTarget(uint guid)
- {
- if (LastSpawns.TryGetValue(guid, out var spawn))
- {
- // Authoritative path: server published Useability.
- // 2026-05-16 — retail-faithful gate per ItemUses::IsUseable
- // at acclient_2013_pseudo_c.txt:256455 (4 call-site cross-
- // checks confirm: ItemHolder::UseObject 0x00588a80,
- // DetermineUseResult 0x402697, UsingItem 0x367638,
- // disable-button state 0x198826 — all key off non-zero).
- // BN's `!(x) & 1` rendering is a mis-decompile of the
- // setne+and test-flag inliner. Real semantic:
- //
- // IsUseable(_useability) := (_useability != USEABLE_UNDEF)
- //
- // ANY non-zero value passes (including USEABLE_NO=1,
- // USEABLE_CONTAINED=8, etc.). Retail trusts the server to
- // have only set non-zero on entities where Use is sensible.
- //
- // Previous implementation (B.8) checked
- // `(useability & USEABLE_REMOTE_BIT) != 0` which is STRICTER
- // than retail — a USEABLE_NO door would be blocked locally
- // but pass retail's gate. Now matches retail bit-for-bit.
- if (spawn.Useability is uint useability)
- {
- // Retail-faithful Use gate per acclient_2013_pseudo_c.txt:256455
- // ItemUses::IsUseable: non-zero useability passes. But two
- // values produce "cannot be used" client-side without a
- // wire send in retail's observable behaviour:
- // USEABLE_UNDEF (0): server's Use handler would reject;
- // retail UseObject path shows "cannot be used" toast.
- // USEABLE_NO (1): explicitly not useable — same outcome.
- // Both come from acclient.h:6478 ITEM_USEABLE enum.
- //
- // Retail technically sends the packet for USEABLE_NO (the
- // audit's `IsUseable != 0` reading is correct), but ACE
- // never broadcasts MovementType=6 for it, so retail
- // doesn't visibly approach. Our client installs a
- // speculative auto-walk overlay BEFORE the server
- // response — so the only way to avoid "approach then fail"
- // is to gate USEABLE_NO client-side. Net result matches
- // user-observed retail behaviour.
- const uint USEABLE_UNDEF = 0u;
- const uint USEABLE_NO = 1u;
- if (useability == USEABLE_UNDEF || useability == USEABLE_NO)
- return false;
- return true;
- }
-
- // Useability NOT in PWD — fall back to known-useable types.
- // ObjectDescriptionFlags BF_DOOR|BF_LIFESTONE|BF_PORTAL|BF_CORPSE
- // historically work with Use; allow them through.
- if (spawn.ObjectDescriptionFlags is { } odf)
- {
- const uint UseableFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
- if ((odf & UseableFlatMask) != 0)
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
- Console.WriteLine(System.FormattableString.Invariant(
- $"[useability-fallback] flat-class guid=0x{guid:X8} odf=0x{odf:X8} (ACE sent no useability bit)"));
- return true;
- }
- }
- }
-
- // Creatures (NPCs / players) are always Use targets in our
- // fallback even when ACE didn't publish useability. Retail
- // would have blocked here (null → USEABLE_UNDEF → 0 → block),
- // but ACE's seed DB has many talk-only NPC weenies with
- // `ItemUseable = null`; without the fallback the M1 "click NPC"
- // flow regresses. The diagnostic line below lets us measure
- // how often this branch fires in real play.
- if ((LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0)
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
- Console.WriteLine(System.FormattableString.Invariant(
- $"[useability-fallback] creature guid=0x{guid:X8} (ACE sent no useability bit)"));
- return true;
- }
-
- // Default: not useable. Signs, banners, untyped scenery with no
- // server-supplied useability and no creature/door PWD bits land
- // here — exactly the retail "nothing happens" case.
- return false;
- }
+ => _worldSelectionQuery?.IsUseable(guid) == true;
///
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
@@ -14597,63 +14352,16 @@ public sealed class GameWindow : IDisposable
///
///
private bool IsPickupableTarget(uint guid)
- {
- if (!LastSpawns.TryGetValue(guid, out var spawn))
- return false;
-
- // 2026-05-16 — primary discriminator is the BF_STUCK
- // ObjectDescriptionFlag (acclient.h:6435, bit 0x4). Retail and
- // ACE mark immovable world objects (signs, banners, doors,
- // benches) as Stuck server-side. ACE's PutItemInContainer
- // handler (Player_Inventory.cs:831-836) responds with
- // WeenieError.Stuck (0x29) when the client attempts a pickup
- // on an item with the Stuck flag — so the client should gate
- // out signs etc. before sending the wire packet.
- //
- // Discriminates same-ItemType ambiguity that useability can't:
- // Holtburg sign (Misc + USEABLE_NO + BF_STUCK) → block
- // Spell component (Misc + USEABLE_NO + ~BF_STUCK) → allow
- // Door (no SmallItemMask + BF_DOOR + BF_STUCK) → never matches SmallItemMask, separately
- if (spawn.ObjectDescriptionFlags is { } odf)
- {
- const uint BF_STUCK = 0x0004u;
- // Corpses are BF_STUCK containers: Use opens their contents; the
- // corpse object itself is never picked up. ItemHolder::DetermineUseResult
- // @ 0x00588460 excludes Stuck objects from PlaceInBackpack.
- if ((odf & BF_STUCK) != 0u) return false;
- }
-
- // Small-item ItemType class: dropped weapons, armor, food,
- // jewelry, money, misc, gems, spell components, etc.
- uint it = spawn.ItemType ?? 0u;
- const uint SmallItemMask =
- (uint)(AcDream.Core.Items.ItemType.MeleeWeapon
- | AcDream.Core.Items.ItemType.Armor
- | AcDream.Core.Items.ItemType.Clothing
- | AcDream.Core.Items.ItemType.Jewelry
- | AcDream.Core.Items.ItemType.Food
- | AcDream.Core.Items.ItemType.Money
- | AcDream.Core.Items.ItemType.Misc
- | AcDream.Core.Items.ItemType.MissileWeapon
- | AcDream.Core.Items.ItemType.Container
- | AcDream.Core.Items.ItemType.Gem
- | AcDream.Core.Items.ItemType.SpellComponents
- | AcDream.Core.Items.ItemType.Writable
- | AcDream.Core.Items.ItemType.Key
- | AcDream.Core.Items.ItemType.Caster);
- return (it & SmallItemMask) != 0u;
- }
+ => _worldSelectionQuery?.IsPickupable(guid) == true;
private AcDream.Core.Items.ItemType LiveItemType(uint guid) =>
- Objects.Get(guid)?.Type ?? AcDream.Core.Items.ItemType.None;
+ _worldSelectionQuery?.GetItemType(guid) ?? AcDream.Core.Items.ItemType.None;
private string? LiveName(uint guid) => Objects.Get(guid)?.Name;
private string DescribeLiveEntity(uint guid)
{
- var name = LiveName(guid);
- if (!string.IsNullOrWhiteSpace(name)) return name!;
- return $"0x{guid:X8}";
+ return _worldSelectionQuery?.Describe(guid) ?? $"0x{guid:X8}";
}
///
diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs
index 65d8c7ad..77400276 100644
--- a/src/AcDream.App/World/LiveEntityRuntime.cs
+++ b/src/AcDream.App/World/LiveEntityRuntime.cs
@@ -15,6 +15,7 @@ namespace AcDream.App.World;
public interface ILiveEntityAnimationRuntime
{
WorldEntity Entity { get; }
+ uint CurrentMotion { get; }
}
/// Remote motion state owned by a live object.
diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs
new file mode 100644
index 00000000..0bb1ed9f
--- /dev/null
+++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs
@@ -0,0 +1,320 @@
+using System.Numerics;
+using AcDream.App.Interaction;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Selection;
+using AcDream.App.Streaming;
+using AcDream.App.World;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.Selection;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.Tests.Interaction;
+
+public sealed class WorldSelectionQueryTests
+{
+ private const uint Player = 0x5000_0001u;
+ private const uint Target = 0x7000_0001u;
+
+ private sealed class Resources : ILiveEntityResourceLifecycle
+ {
+ public void Register(WorldEntity entity) { }
+ public void Unregister(WorldEntity entity) { }
+ }
+
+ private sealed class GeometrySource(RetailSelectionMesh mesh)
+ : IRetailSelectionGeometrySource
+ {
+ public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh;
+ }
+
+ private sealed class Harness
+ {
+ public readonly ClientObjectTable Objects = new();
+ public readonly LiveEntityRuntime Runtime;
+ public readonly RetailSelectionScene Scene;
+ public readonly WorldSelectionQuery Query;
+ public PlayerInteractionPose? PlayerPose = new(0x0101_0001u, Vector3.Zero);
+
+ public Harness()
+ {
+ var spatial = new GpuWorldState();
+ spatial.AddLandblock(new LoadedLandblock(
+ 0x0101_FFFFu,
+ new LandBlock(),
+ Array.Empty()));
+ Runtime = new LiveEntityRuntime(spatial, new Resources());
+ Scene = new RetailSelectionScene(new GeometrySource(Mesh()));
+ Query = new WorldSelectionQuery(
+ Runtime,
+ Objects,
+ Scene,
+ () => Player,
+ Camera,
+ () => new Vector2(400f, 300f),
+ () => PlayerPose,
+ (_, _) => (0.5f, 2f),
+ _ => (new Vector3(1f, 0f, 0f), 2f));
+
+ Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer);
+ }
+
+ public WorldEntity Add(
+ uint guid,
+ Vector3 position,
+ ItemType type,
+ uint publicFlags = 0u,
+ uint? useability = null,
+ uint objectDescriptionFlags = 0u,
+ ushort instance = 1,
+ float scale = 1f,
+ Quaternion? rotation = null)
+ {
+ WorldSession.EntitySpawn spawn = Spawn(guid, instance) with
+ {
+ ItemType = (uint)type,
+ Useability = useability,
+ ObjectDescriptionFlags = objectDescriptionFlags,
+ };
+ Runtime.RegisterLiveEntity(spawn);
+ WorldEntity entity = Runtime.MaterializeLiveEntity(
+ guid,
+ 0x0101_0001u,
+ id => Entity(id, guid, position, scale, rotation ?? Quaternion.Identity))!;
+ Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = guid,
+ Name = $"Object {guid:X8}",
+ Type = type,
+ PublicWeenieBitfield = publicFlags,
+ });
+ return entity;
+ }
+
+ public void Publish(WorldEntity entity)
+ {
+ SelectionCameraSnapshot camera = Camera();
+ Scene.BeginFrame();
+ Scene.SetViewFrustum(FrustumPlanes.FromViewProjection(
+ camera.View * camera.Projection));
+ Scene.AddVisiblePart(
+ entity,
+ 0,
+ 0x0100_0001u,
+ Matrix4x4.CreateTranslation(entity.Position));
+ Scene.CompleteFrame();
+ }
+ }
+
+ [Fact]
+ public void PickRejectsPublishedPartAfterGuidWasReused()
+ {
+ var h = new Harness();
+ WorldEntity oldTarget = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc);
+ h.Publish(oldTarget);
+ Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false));
+
+ Assert.True(h.Runtime.UnregisterLiveEntity(
+ new DeleteObject.Parsed(Target, 1),
+ isLocalPlayer: false));
+ WorldEntity replacement = h.Add(
+ Target,
+ new Vector3(0f, 0f, -5f),
+ ItemType.Misc,
+ instance: 1);
+
+ Assert.NotEqual(oldTarget.Id, replacement.Id);
+ Assert.Null(h.Query.PickAtCursor(includeSelf: false));
+ }
+
+ [Fact]
+ public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters()
+ {
+ var h = new Harness();
+ h.Add(
+ 0x7000_0010u,
+ new Vector3(8f, 0f, 0f),
+ ItemType.Creature,
+ SelectedObjectHealthPolicy.BfAttackable);
+ h.Add(
+ 0x7000_0011u,
+ new Vector3(2f, 0f, 0f),
+ ItemType.Creature,
+ publicFlags: 0u);
+ h.Add(
+ 0x7000_0012u,
+ new Vector3(1f, 0f, 0f),
+ ItemType.Misc,
+ SelectedObjectHealthPolicy.BfAttackable);
+
+ ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster();
+
+ Assert.Equal(0x7000_0010u, closest?.ServerGuid);
+ Assert.Equal(64f, closest?.DistanceSquared);
+ }
+
+ [Theory]
+ [InlineData(0.59f, true)]
+ [InlineData(0.61f, false)]
+ [InlineData(7.49f, false)]
+ [InlineData(7.50f, true)]
+ public void ApproachUsesRetailGroundItemRadiusAndAceChargeBoundary(
+ float distance,
+ bool expectedBoundary)
+ {
+ var h = new Harness();
+ h.Add(Target, new Vector3(distance, 0f, 0f), ItemType.Misc);
+
+ Assert.True(h.Query.TryGetApproach(Target, out InteractionApproach approach));
+
+ if (distance < 1f)
+ Assert.Equal(expectedBoundary, approach.IsCloseRange);
+ else
+ Assert.Equal(expectedBoundary, approach.CanCharge);
+ Assert.Equal(0.6f, approach.UseRadius);
+ Assert.Equal(0.5f, approach.TargetRadius);
+ Assert.Equal(2f, approach.TargetHeight);
+ }
+
+ [Fact]
+ public void PickupAndUseabilityPreserveIndependentRetailGates()
+ {
+ var h = new Harness();
+ const uint stuckComponent = 0x7000_0020u;
+ const uint looseComponent = 0x7000_0021u;
+ h.Add(
+ stuckComponent,
+ Vector3.UnitX,
+ ItemType.SpellComponents,
+ useability: 1u,
+ objectDescriptionFlags: 0x0004u);
+ h.Add(
+ looseComponent,
+ Vector3.UnitX * 2f,
+ ItemType.SpellComponents,
+ useability: 1u);
+
+ Assert.False(h.Query.IsUseable(stuckComponent));
+ Assert.False(h.Query.IsPickupable(stuckComponent));
+ Assert.False(h.Query.IsUseable(looseComponent));
+ Assert.True(h.Query.IsPickupable(looseComponent));
+ }
+
+ [Fact]
+ public void SelectionSphereAppliesSetupOffsetScaleAndRotation()
+ {
+ var h = new Harness();
+ h.Add(
+ Target,
+ new Vector3(10f, 20f, 3f),
+ ItemType.Misc,
+ scale: 2f,
+ rotation: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
+
+ Assert.True(h.Query.TryGetSelectionSphere(Target, out Vector3 center, out float radius));
+
+ Assert.True(Vector3.Distance(new Vector3(10f, 22f, 3f), center) < 0.0001f);
+ Assert.Equal(4f, radius);
+ }
+
+ private static SelectionCameraSnapshot Camera()
+ {
+ Matrix4x4 view = Matrix4x4.CreateLookAt(
+ Vector3.Zero,
+ -Vector3.UnitZ,
+ Vector3.UnitY);
+ Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI / 3f,
+ 4f / 3f,
+ 0.1f,
+ 100f);
+ return new SelectionCameraSnapshot(view, projection, new Vector2(800f, 600f));
+ }
+
+ private static RetailSelectionMesh Mesh()
+ => new(
+ Vector3.Zero,
+ 2f,
+ [new RetailSelectionPolygon(
+ [
+ new(-1f, -1f, 0f),
+ new( 1f, -1f, 0f),
+ new( 1f, 1f, 0f),
+ new(-1f, 1f, 0f),
+ ],
+ SingleSided: false)]);
+
+ private static WorldEntity Entity(
+ uint id,
+ uint guid,
+ Vector3 position,
+ float scale,
+ Quaternion rotation)
+ => new()
+ {
+ Id = id,
+ ServerGuid = guid,
+ SourceGfxObjOrSetupId = 0x0200_0001u,
+ Position = position,
+ Rotation = rotation,
+ Scale = scale,
+ MeshRefs = [],
+ };
+
+ private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
+ {
+ var position = new CreateObject.ServerPosition(
+ 0x0101_0001u,
+ 10f,
+ 10f,
+ 5f,
+ 1f,
+ 0f,
+ 0f,
+ 0f);
+ var physics = new PhysicsSpawnData(
+ RawState: (uint)PhysicsStateFlags.ReportCollisions,
+ Position: position,
+ Movement: null,
+ AnimationFrame: null,
+ SetupTableId: 0x0200_0001u,
+ MotionTableId: 0x0900_0001u,
+ SoundTableId: null,
+ PhysicsScriptTableId: null,
+ Parent: null,
+ Children: null,
+ Scale: null,
+ Friction: null,
+ Elasticity: null,
+ Translucency: null,
+ Velocity: null,
+ Acceleration: null,
+ AngularVelocity: null,
+ DefaultScriptType: null,
+ DefaultScriptIntensity: null,
+ Timestamps: new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, instance));
+ return new WorldSession.EntitySpawn(
+ guid,
+ position,
+ 0x0200_0001u,
+ [],
+ [],
+ [],
+ null,
+ null,
+ "fixture",
+ null,
+ null,
+ 0x0900_0001u,
+ PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
+ InstanceSequence: instance,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1,
+ Physics: physics);
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
index b578950f..ebf3c29e 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
@@ -28,6 +28,7 @@ public sealed class LiveEntityRuntimeTests
private sealed class AnimationRuntime(WorldEntity entity) : ILiveEntityAnimationRuntime
{
public WorldEntity Entity { get; } = entity;
+ public uint CurrentMotion { get; init; }
}
private sealed class EffectProfile : ILiveEntityEffectProfile { }