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

@ -63,6 +63,7 @@ public sealed class CombatTargetController : IDisposable
private void OnSelectionChanged(SelectionTransition transition)
{
if (transition.SelectedObjectId is not null
|| transition.Reason == SelectionChangeReason.SessionReset
|| !_autoTarget()
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
return;

View file

@ -1168,6 +1168,7 @@ public sealed class GameWindow : IDisposable
private readonly record struct PendingPostArrivalAction(
uint Guid,
uint LocalEntityId,
bool IsPickup,
uint DestinationContainerId = 0u,
int Placement = 0);
@ -2982,10 +2983,11 @@ public sealed class GameWindow : IDisposable
Objects.Clear();
SpellBook.Clear();
_magicRuntime?.Reset();
_itemInteractionController?.ClearBusy();
_itemInteractionController?.ResetSession();
_selection.Reset();
_outboundInteractions.Clear();
_pendingPostArrivalAction = null;
_retailSelectionScene?.Reset();
_particleVisibility.Reset();
try
{
@ -5490,14 +5492,15 @@ public sealed class GameWindow : IDisposable
() => _liveEntityPresentation?.Forget(record),
() => _entityEffects?.OnLiveEntityUnregistered(record),
() => _remoteTeleportController?.Forget(record),
() => incarnation.RunIfNoReplacement(() =>
() =>
{
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
&& pendingGuid == serverGuid)
&& pendingGuid == serverGuid
&& _pendingPostArrivalAction.Value.LocalEntityId == record.LocalEntityId)
{
_pendingPostArrivalAction = null;
}
}),
},
};
if (record.WorldEntity is not { } existingEntity)
@ -13049,7 +13052,7 @@ public sealed class GameWindow : IDisposable
{
uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u;
if (target != 0u)
_retailSelectionScene?.BeginLightingPulse(target);
BeginSelectionLightingPulse(target);
_itemInteractionController?.PlaceIn3D(itemPayload, target);
}
}
@ -13765,16 +13768,40 @@ public sealed class GameWindow : IDisposable
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
{
if (_retailSelectionScene is null || _window is null)
if (_retailSelectionScene is null
|| _liveEntities is null
|| _window is null)
return null;
var camera = GetSelectionCamera();
return _retailSelectionScene.Pick(
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)
{
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)
@ -13791,7 +13818,7 @@ public sealed class GameWindow : IDisposable
// Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
// @ 0x004E5AD0 pulses every successfully found click before it
// branches into select/use/targeted-use behavior.
_retailSelectionScene?.BeginLightingPulse(guid);
BeginSelectionLightingPulse(guid);
if (_itemInteractionController?.OfferPrimaryClick(guid)
is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive)
@ -13890,6 +13917,13 @@ 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)
{
return;
}
bool closeRange = IsCloseRangeTarget(guid);
InstallSpeculativeTurnToTarget(guid);
@ -13897,7 +13931,10 @@ public sealed class GameWindow : IDisposable
{
// Defer the wire packet — OnAutoWalkArrivedSendDeferredAction
// will fire it after rotation completes.
_pendingPostArrivalAction = new PendingPostArrivalAction(guid, IsPickup: false);
_pendingPostArrivalAction = new PendingPostArrivalAction(
guid,
useTargetLocalId,
IsPickup: false);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
return;
@ -13979,6 +14016,14 @@ public sealed class GameWindow : IDisposable
// overlay reports arrival.
//
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
if (_liveEntities?.TryGetInteractionEligibleRecord(
itemGuid,
out LiveEntityRecord pickupTarget) != true
|| pickupTarget.LocalEntityId is not { } pickupTargetLocalId)
{
return;
}
bool closeRange = IsCloseRangeTarget(itemGuid);
InstallSpeculativeTurnToTarget(itemGuid);
@ -13986,6 +14031,7 @@ public sealed class GameWindow : IDisposable
{
_pendingPostArrivalAction = new PendingPostArrivalAction(
itemGuid,
pickupTargetLocalId,
IsPickup: true,
destinationContainerId,
placement);
@ -14031,6 +14077,13 @@ public sealed class GameWindow : IDisposable
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return;
if (_liveEntities?.TryGetInteractionEligibleRecord(
pending.Guid,
pending.LocalEntityId,
out _) != true)
{
return;
}
var seq = _liveSession.NextGameActionSequence();
if (pending.IsPickup)

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(

View file

@ -1527,7 +1527,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
_currentEntitySelectionLighting =
_selectionLighting?.TryGetLighting(entity.ServerGuid, out var lighting) == true
_selectionLighting?.TryGetLighting(
entity.ServerGuid,
entity.Id,
out var lighting) == true
? new Vector2(lighting.Luminosity, lighting.Diffuse)
: new Vector2(0f, 1f);

View file

@ -587,6 +587,24 @@ public sealed class ItemInteractionController : IDisposable
StateChanged?.Invoke();
}
/// <summary>
/// Ends all session-scoped ItemHolder state. Target modes, click
/// suppression, and use throttles must not survive a reconnect where the
/// same numeric GUID can identify a different object.
/// </summary>
public void ResetSession()
{
bool busyChanged = _busyCount != 0;
bool modeChanged = IsAnyTargetModeActive;
_busyCount = 0;
_lastUseMs = long.MinValue / 2;
_consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2;
_interactionState.Clear();
if (busyChanged && !modeChanged)
StateChanged?.Invoke();
}
private bool ConsumeUseThrottle()
{
long now = _nowMs();

View file

@ -964,6 +964,46 @@ public sealed class LiveEntityRuntime
out WorldEntity entity) =>
_visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!);
public bool TryGetInteractionEligibleRecord(
uint serverGuid,
out LiveEntityRecord record)
{
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? found)
&& found.WorldEntity is { } entity
&& _visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out WorldEntity? visible)
&& ReferenceEquals(entity, visible))
{
record = found;
return true;
}
record = null!;
return false;
}
/// <summary>
/// Resolves a render-published interaction candidate only while the same
/// logical WorldEntity incarnation still owns the server GUID. A stale
/// frame must never retarget a replacement which reused that GUID.
/// </summary>
public bool TryGetInteractionEligibleRecord(
uint serverGuid,
uint localEntityId,
out LiveEntityRecord record)
{
if (serverGuid != 0u
&& localEntityId != 0u
&& TryGetInteractionEligibleRecord(serverGuid, out LiveEntityRecord found)
&& found.WorldEntity!.Id == localEntityId)
{
record = found;
return true;
}
record = null!;
return false;
}
public bool ContainsWorldEntity(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.WorldEntity is not null;

View file

@ -20,6 +20,7 @@ public sealed record RetailSelectionPolygon(
/// <summary>One part which survived the normal world-render visibility traversal.</summary>
public readonly record struct RetailSelectionPart(
uint ServerGuid,
uint LocalEntityId,
int PartIndex,
Matrix4x4 LocalToWorld,
RetailSelectionMesh Mesh);
@ -27,6 +28,7 @@ public readonly record struct RetailSelectionPart(
/// <summary>Retail picker result, including which physics part supplied the hit.</summary>
public readonly record struct RetailSelectionHit(
uint ServerGuid,
uint LocalEntityId,
int PartIndex,
double Distance,
bool PolygonHit);

View file

@ -53,7 +53,7 @@ public static class RetailWorldPicker
if (closestSphere is null || sphereT < closestSphere.Value.Distance)
closestSphere = new RetailSelectionHit(
part.ServerGuid, part.PartIndex, sphereT, PolygonHit: false);
part.ServerGuid, part.LocalEntityId, part.PartIndex, sphereT, PolygonHit: false);
// Retail stops at the FIRST hit polygon in this part's stored flat order.
foreach (var polygon in part.Mesh.Polygons)
@ -63,7 +63,7 @@ public static class RetailWorldPicker
if (closestPolygon is null || polygonT < closestPolygon.Value.Distance)
closestPolygon = new RetailSelectionHit(
part.ServerGuid, part.PartIndex, polygonT, PolygonHit: true);
part.ServerGuid, part.LocalEntityId, part.PartIndex, polygonT, PolygonHit: true);
break;
}
}