refactor(interaction): extract world selection query

Move render-hit validation, live target classification, closest-hostile search, selection spheres, use/pickup gates, range checks, and approach parameters behind a read-only App-layer query owner. GameWindow now supplies runtime seams and delegates instead of owning those algorithms.
This commit is contained in:
Erik 2026-07-21 07:16:22 +02:00
parent 52dbb5749e
commit e74f2ca99a
5 changed files with 749 additions and 371 deletions

View file

@ -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);
/// <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
{
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<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;
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)
{
_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);
}
/// <summary>
/// SmartBox::GetObjectBoundingBox @ 0x00452E20 delegates to
/// CPhysicsObj::GetSelectionSphere @ 0x0050EA40 and uses a 0.1-unit
/// fallback when Setup authored no sphere.
/// </summary>
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;
}
/// <summary>ItemUses::IsUseable @ retail 0x00566A20 call family.</summary>
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;
}
/// <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;
}
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;
}
}

View file

@ -44,6 +44,7 @@ public sealed class GameWindow : IDisposable
private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler; private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene; private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup — missing bindless throws /// support. Required at startup — missing bindless throws
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary> /// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
@ -304,6 +305,8 @@ public sealed class GameWindow : IDisposable
{ {
public required AcDream.Core.World.WorldEntity Entity; public required AcDream.Core.World.WorldEntity Entity;
AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => 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.Setup Setup;
public required DatReaderWriter.DBObjs.Animation Animation; public required DatReaderWriter.DBObjs.Animation Animation;
public required int LowFrame; public required int LowFrame;
@ -2743,6 +2746,40 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache( new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
_dats!, _datLock)), _dats!, _datLock)),
_retailAlphaQueue); _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<DatReaderWriter.DBObjs.Setup>(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. // A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@ -13740,14 +13777,10 @@ public sealed class GameWindow : IDisposable
{ {
if (!_persistedGameplay.ViewCombatTarget if (!_persistedGameplay.ViewCombatTarget
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode) || !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
|| _selection.SelectedObjectId is not uint selected || _selection.SelectedObjectId is not uint selected)
|| !IsLiveHostileMonsterTarget(selected)
|| !_visibleEntitiesByServerGuid.TryGetValue(selected, out var target))
return null; return null;
return target.Position return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected);
+ System.Numerics.Vector3.Transform(
new System.Numerics.Vector3(0f, 0f, 0.5f), target.Rotation);
} }
// ============================================================ // ============================================================
@ -13765,45 +13798,13 @@ public sealed class GameWindow : IDisposable
/// pick the local player while plain selection excludes it. /// pick the local player while plain selection excludes it.
/// </summary> /// </summary>
private uint? PickWorldGuidAtCursor(bool includeSelf) private uint? PickWorldGuidAtCursor(bool includeSelf)
=> PickWorldGuidAt(_lastMouseX, _lastMouseY, includeSelf); => _worldSelectionQuery?.PickAtCursor(includeSelf);
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf) private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
{ => _worldSelectionQuery?.PickAt(mouseX, mouseY, 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;
}
private void BeginSelectionLightingPulse(uint serverGuid) private void BeginSelectionLightingPulse(uint serverGuid)
{ => _worldSelectionQuery?.BeginLightingPulse(serverGuid);
if (_retailSelectionScene is null
|| _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord record) != true
|| record.LocalEntityId is not { } localEntityId)
{
return;
}
_retailSelectionScene.BeginLightingPulse(serverGuid, localEntityId);
}
private void PickAndStoreSelection(bool useImmediately) private void PickAndStoreSelection(bool useImmediately)
{ {
@ -13918,9 +13919,9 @@ public sealed class GameWindow : IDisposable
// Close-range deferral fires the wire packet ONCE on // Close-range deferral fires the wire packet ONCE on
// MoveToComplete(None) (turn-first done), not a retry of an // MoveToComplete(None) (turn-first done), not a retry of an
// earlier failed send. No re-send path. // earlier failed send. No re-send path.
if (_liveEntities?.TryGetInteractionEligibleRecord(guid, out LiveEntityRecord useTarget) if (_worldSelectionQuery?.TryGetInteractionTarget(
!= true guid,
|| useTarget.LocalEntityId is not { } useTargetLocalId) out AcDream.App.Interaction.WorldInteractionTarget useTarget) != true)
{ {
return; return;
} }
@ -13934,7 +13935,7 @@ public sealed class GameWindow : IDisposable
// will fire it after rotation completes. // will fire it after rotation completes.
_pendingPostArrivalAction = new PendingPostArrivalAction( _pendingPostArrivalAction = new PendingPostArrivalAction(
guid, guid,
useTargetLocalId, useTarget.LocalEntityId,
IsPickup: false); IsPickup: false);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}"); 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. // overlay reports arrival.
// //
// 2026-05-16: simplified — FIRST send on arrival, not a retry. // 2026-05-16: simplified — FIRST send on arrival, not a retry.
if (_liveEntities?.TryGetInteractionEligibleRecord( if (_worldSelectionQuery?.TryGetInteractionTarget(
itemGuid, itemGuid,
out LiveEntityRecord pickupTarget) != true out AcDream.App.Interaction.WorldInteractionTarget pickupTarget) != true)
|| pickupTarget.LocalEntityId is not { } pickupTargetLocalId)
{ {
return; return;
} }
@ -14029,7 +14029,7 @@ public sealed class GameWindow : IDisposable
{ {
_pendingPostArrivalAction = new PendingPostArrivalAction( _pendingPostArrivalAction = new PendingPostArrivalAction(
itemGuid, itemGuid,
pickupTargetLocalId, pickupTarget.LocalEntityId,
IsPickup: true, IsPickup: true,
destinationContainerId, destinationContainerId,
placement); placement);
@ -14075,10 +14075,9 @@ public sealed class GameWindow : IDisposable
if (_liveSession is null if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return; return;
if (_liveEntities?.TryGetInteractionEligibleRecord( if (_worldSelectionQuery?.IsCurrent(
pending.Guid, pending.Guid,
pending.LocalEntityId, pending.LocalEntityId) != true)
out _) != true)
{ {
return; return;
} }
@ -14132,89 +14131,21 @@ public sealed class GameWindow : IDisposable
/// until our local rotation overlay reports alignment, then fire. /// until our local rotation overlay reports alignment, then fire.
/// </summary> /// </summary>
private bool IsCloseRangeTarget(uint targetGuid) private bool IsCloseRangeTarget(uint targetGuid)
{ => _worldSelectionQuery?.TryGetApproach(targetGuid, out var approach) == true
if (_playerController is null) return false; && approach.IsCloseRange;
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;
}
private bool IsWithinExternalContainerUseRange(uint targetGuid) private bool IsWithinExternalContainerUseRange(uint targetGuid)
{ => _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
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);
}
private void InstallSpeculativeTurnToTarget(uint targetGuid) private void InstallSpeculativeTurnToTarget(uint targetGuid)
{ {
if (_playerController is not { } pc || pc.MoveTo is null) return; if (_playerController is not { } pc
if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity)) || pc.MoveTo is null
|| _worldSelectionQuery?.TryGetApproach(
targetGuid,
out AcDream.App.Interaction.InteractionApproach approach) != true)
return; 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 // Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
// from local distance so the speculative moveto uses the // from local distance so the speculative moveto uses the
// same walk/run as the wire-triggered overwrite that arrives // 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 // the speculative install doesn't flip walk↔run when ACE's
// MoveToObject broadcast overwrites it (PerformMovement // MoveToObject broadcast overwrites it (PerformMovement
// cancels + restarts — retail-consistent re-target). // 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 / // R4-V5: retail's client-initiated use flow issues TurnToObject /
// MoveToObject through the SAME manager the wire path uses (decomp // MoveToObject through the SAME manager the wire path uses (decomp
// §9a/§9b callers) — in-range targets get a pure turn-to-face; // §9a/§9b callers) — in-range targets get a pure turn-to-face;
@ -14242,18 +14166,18 @@ public sealed class GameWindow : IDisposable
// are non-default. // are non-default.
var p = new AcDream.Core.Physics.Motion.MovementParameters var p = new AcDream.Core.Physics.Motion.MovementParameters
{ {
DistanceToObject = useRadius, DistanceToObject = approach.UseRadius,
CanCharge = speculativeCanCharge, CanCharge = approach.CanCharge,
}; };
var ms = new AcDream.Core.Physics.MovementStruct var ms = new AcDream.Core.Physics.MovementStruct
{ {
ObjectId = targetGuid, ObjectId = targetGuid,
TopLevelId = targetGuid, TopLevelId = targetGuid,
Pos = new AcDream.Core.Physics.Position( Pos = new AcDream.Core.Physics.Position(
_playerController.CellId, entity.Position, approach.Player.CellId, approach.Target.Entity.Position,
System.Numerics.Quaternion.Identity), System.Numerics.Quaternion.Identity),
Params = p, Params = p,
Type = IsCloseRangeTarget(targetGuid) Type = approach.IsCloseRange
? AcDream.Core.Physics.MovementType.TurnToObject ? AcDream.Core.Physics.MovementType.TurnToObject
: AcDream.Core.Physics.MovementType.MoveToObject, : AcDream.Core.Physics.MovementType.MoveToObject,
}; };
@ -14265,7 +14189,7 @@ public sealed class GameWindow : IDisposable
// arrival hybrid (centerDist playerRadius ≤ useRadius) — the // arrival hybrid (centerDist playerRadius ≤ useRadius) — the
// auto-walk stopped ~one player-radius farther out than the AP-23 // auto-walk stopped ~one player-radius farther out than the AP-23
// constants intended, and the two MoveToObject sites disagreed. // 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 // Part of this install's AP-23 adaptation: store the autonomy flag
// exactly as the wire mt-6 this install anticipates would (the P1 // exactly as the wire mt-6 this install anticipates would (the P1
// unpack store, IsAutonomous=false) — without it the per-tick // unpack store, IsAutonomous=false) — without it the per-tick
@ -14281,25 +14205,9 @@ public sealed class GameWindow : IDisposable
private uint? SelectClosestCombatTarget(bool showToast) private uint? SelectClosestCombatTarget(bool showToast)
{ {
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) AcDream.App.Interaction.ClosestCombatTarget? closest =
return null; _worldSelectionQuery?.FindClosestHostileMonster();
uint? bestGuid = closest?.ServerGuid;
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;
}
if (bestGuid is { } selection) if (bestGuid is { } selection)
_selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard); _selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard);
@ -14308,7 +14216,7 @@ public sealed class GameWindow : IDisposable
if (bestGuid is { } selected) if (bestGuid is { } selected)
{ {
string label = DescribeLiveEntity(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}"); Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
if (showToast) if (showToast)
_debugVm?.AddToast($"Target {label}"); _debugVm?.AddToast($"Target {label}");
@ -14323,30 +14231,10 @@ public sealed class GameWindow : IDisposable
} }
private bool IsLiveCreatureTarget(uint guid) private bool IsLiveCreatureTarget(uint guid)
{ => _worldSelectionQuery?.IsCreature(guid) == true;
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;
}
private bool IsLiveHostileMonsterTarget(uint guid) private bool IsLiveHostileMonsterTarget(uint guid)
{ => _worldSelectionQuery?.IsHostileMonster(guid) == true;
if (!IsLiveCreatureTarget(guid))
return false;
return AcDream.Core.Combat.CombatTargetPolicy.IsHostileMonster(
_playerServerGuid,
Objects.Get(_playerServerGuid),
Objects.Get(guid));
}
/// <summary> /// <summary>
/// True if the selected-object strip should show a Health meter for <paramref name="guid"/>. /// True if the selected-object strip should show a Health meter for <paramref name="guid"/>.
@ -14355,10 +14243,7 @@ public sealed class GameWindow : IDisposable
/// <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>. /// <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>.
/// </summary> /// </summary>
private bool IsHealthBarTarget(uint guid) private bool IsHealthBarTarget(uint guid)
=> AcDream.Core.Combat.SelectedObjectHealthPolicy.ShouldQueryHealth( => _worldSelectionQuery?.ShouldShowHealth(guid) == true;
_playerServerGuid,
Objects.Get(_playerServerGuid),
Objects.Get(guid));
private (System.Numerics.Matrix4x4 View, private (System.Numerics.Matrix4x4 View,
System.Numerics.Matrix4x4 Projection, System.Numerics.Matrix4x4 Projection,
@ -14374,20 +14259,7 @@ public sealed class GameWindow : IDisposable
} }
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid) private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
{ => _worldSelectionQuery?.ResolveVividTargetInfo(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);
}
/// <summary> /// <summary>
@ -14420,45 +14292,11 @@ public sealed class GameWindow : IDisposable
out System.Numerics.Vector3 worldCenter, out System.Numerics.Vector3 worldCenter,
out float worldRadius) out float worldRadius)
{ {
if (_worldSelectionQuery is { } query)
return query.TryGetSelectionSphere(guid, out worldCenter, out worldRadius);
worldCenter = default; worldCenter = default;
worldRadius = 0f; worldRadius = 0f;
return false;
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<DatReaderWriter.DBObjs.Setup>(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;
} }
/// <summary> /// <summary>
@ -14490,90 +14328,7 @@ public sealed class GameWindow : IDisposable
/// </para> /// </para>
/// </summary> /// </summary>
private bool IsUseableTarget(uint guid) private bool IsUseableTarget(uint guid)
{ => _worldSelectionQuery?.IsUseable(guid) == true;
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;
}
/// <summary> /// <summary>
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based. /// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
@ -14597,63 +14352,16 @@ public sealed class GameWindow : IDisposable
/// </para> /// </para>
/// </summary> /// </summary>
private bool IsPickupableTarget(uint guid) private bool IsPickupableTarget(uint guid)
{ => _worldSelectionQuery?.IsPickupable(guid) == true;
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;
}
private AcDream.Core.Items.ItemType LiveItemType(uint guid) => 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? LiveName(uint guid) => Objects.Get(guid)?.Name;
private string DescribeLiveEntity(uint guid) private string DescribeLiveEntity(uint guid)
{ {
var name = LiveName(guid); return _worldSelectionQuery?.Describe(guid) ?? $"0x{guid:X8}";
if (!string.IsNullOrWhiteSpace(name)) return name!;
return $"0x{guid:X8}";
} }
/// <summary> /// <summary>

View file

@ -15,6 +15,7 @@ namespace AcDream.App.World;
public interface ILiveEntityAnimationRuntime public interface ILiveEntityAnimationRuntime
{ {
WorldEntity Entity { get; } WorldEntity Entity { get; }
uint CurrentMotion { get; }
} }
/// <summary>Remote motion state owned by a live object.</summary> /// <summary>Remote motion state owned by a live object.</summary>

View file

@ -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<WorldEntity>()));
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);
}
}

View file

@ -28,6 +28,7 @@ public sealed class LiveEntityRuntimeTests
private sealed class AnimationRuntime(WorldEntity entity) : ILiveEntityAnimationRuntime private sealed class AnimationRuntime(WorldEntity entity) : ILiveEntityAnimationRuntime
{ {
public WorldEntity Entity { get; } = entity; public WorldEntity Entity { get; } = entity;
public uint CurrentMotion { get; init; }
} }
private sealed class EffectProfile : ILiveEntityEffectProfile { } private sealed class EffectProfile : ILiveEntityEffectProfile { }