fix(interaction): bind selection to live incarnations

Carry local WorldEntity identity through render hits, lighting pulses, and deferred movement actions so GUID reuse cannot target a replacement. Reset all session-owned selection and ItemHolder state and prevent combat auto-target during teardown.
This commit is contained in:
Erik 2026-07-21 07:07:58 +02:00
parent c271383714
commit 047a4c83b5
19 changed files with 374 additions and 42 deletions

View file

@ -0,0 +1,12 @@
using AcDream.Core.Selection;
namespace AcDream.App.Rendering.Selection;
/// <summary>
/// Supplies the immutable DAT geometry used by retail's render-coupled picker.
/// The seam keeps frame publication and incarnation tests independent of DAT IO.
/// </summary>
internal interface IRetailSelectionGeometrySource
{
RetailSelectionMesh? Resolve(uint gfxObjId);
}

View file

@ -13,5 +13,8 @@ internal interface IRetailSelectionLightingSource
/// Returns the current CMaterial luminosity/diffuse replacement for an
/// object, or false when its authored material values must be restored.
/// </summary>
bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting);
bool TryGetLighting(
uint serverGuid,
uint localEntityId,
out RetailSelectionLighting lighting);
}

View file

@ -10,7 +10,7 @@ namespace AcDream.App.Rendering.Selection;
/// Decodes the exact CPU geometry consumed by retail mouse selection. The broad
/// sphere comes from the drawing-BSP root; polygons retain GfxObj DAT order.
/// </summary>
internal sealed class RetailSelectionGeometryCache
internal sealed class RetailSelectionGeometryCache : IRetailSelectionGeometrySource
{
private readonly IDatReaderWriter _dats;
private readonly object _datLock;

View file

@ -27,6 +27,7 @@ internal sealed class RetailSelectionLightingPulse
private readonly Func<double> _now;
private uint _serverGuid;
private uint _localEntityId;
private int _flipCount;
private double _nextFlip;
private RetailSelectionLighting _lighting;
@ -34,15 +35,16 @@ internal sealed class RetailSelectionLightingPulse
public RetailSelectionLightingPulse(Func<double>? now = null)
=> _now = now ?? MonotonicSeconds;
public void Start(uint serverGuid)
public void Start(uint serverGuid, uint localEntityId)
{
if (serverGuid == 0u)
if (serverGuid == 0u || localEntityId == 0u)
{
Clear();
return;
}
_serverGuid = serverGuid;
_localEntityId = localEntityId;
_flipCount = 1;
_lighting = RetailSelectionLighting.High;
_nextFlip = _now() + FlipIntervalSeconds;
@ -76,9 +78,16 @@ internal sealed class RetailSelectionLightingPulse
_nextFlip = now + FlipIntervalSeconds;
}
public bool TryGet(uint serverGuid, out RetailSelectionLighting lighting)
public bool TryGet(
uint serverGuid,
uint localEntityId,
out RetailSelectionLighting lighting)
{
if (_flipCount != 0 && serverGuid != 0u && serverGuid == _serverGuid)
if (_flipCount != 0
&& serverGuid != 0u
&& localEntityId != 0u
&& serverGuid == _serverGuid
&& localEntityId == _localEntityId)
{
lighting = _lighting;
return true;
@ -91,6 +100,7 @@ internal sealed class RetailSelectionLightingPulse
public void Clear()
{
_serverGuid = 0u;
_localEntityId = 0u;
_flipCount = 0;
_nextFlip = 0d;
_lighting = default;

View file

@ -11,7 +11,7 @@ namespace AcDream.App.Rendering.Selection;
/// </summary>
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource
{
private readonly RetailSelectionGeometryCache _geometry;
private readonly IRetailSelectionGeometrySource _geometry;
private readonly RetailSelectionLightingPulse _lightingPulse;
private List<RetailSelectionPart> _building = new();
private List<RetailSelectionPart> _published = new();
@ -21,7 +21,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId);
public RetailSelectionScene(
RetailSelectionGeometryCache geometry,
IRetailSelectionGeometrySource geometry,
RetailSelectionLightingPulse? lightingPulse = null)
{
_geometry = geometry ?? throw new ArgumentNullException(nameof(geometry));
@ -32,14 +32,27 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
/// Starts retail's SmartBox click pulse independently of persistent target
/// selection. Examine, use, and targeted-use clicks receive the same pulse.
/// </summary>
public void BeginLightingPulse(uint serverGuid)
=> _lightingPulse.Start(serverGuid);
public void BeginLightingPulse(uint serverGuid, uint localEntityId)
=> _lightingPulse.Start(serverGuid, localEntityId);
public void TickLighting()
=> _lightingPulse.Tick();
public bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting)
=> _lightingPulse.TryGet(serverGuid, out lighting);
public bool TryGetLighting(
uint serverGuid,
uint localEntityId,
out RetailSelectionLighting lighting)
=> _lightingPulse.TryGet(serverGuid, localEntityId, out lighting);
/// <summary>Clears every session-owned frame and material pulse.</summary>
public void Reset()
{
_building.Clear();
_published.Clear();
_buildingKeys.Clear();
_viewFrustum = null;
_lightingPulse.Clear();
}
public void BeginFrame()
{
@ -76,6 +89,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
_building.Add(new RetailSelectionPart(
entity.ServerGuid,
entity.Id,
partIndex,
partWorld,
mesh));
@ -86,7 +100,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
(_published, _building) = (_building, _published);
}
public uint? Pick(
public RetailSelectionHit? Pick(
float mouseX,
float mouseY,
Vector2 viewport,
@ -99,7 +113,7 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
var ray = WorldPicker.BuildRay(
mouseX, mouseY, viewport.X, viewport.Y, view, projection);
return RetailWorldPicker.Pick(
ray.Origin, ray.Direction, _published, skipServerGuid)?.ServerGuid;
ray.Origin, ray.Direction, _published, skipServerGuid);
}
internal static bool DrawingSphereIntersectsFrustum(