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 { }