diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index dbe75065..65106aa7 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -102,7 +102,7 @@ ownership mutation.
## #230 — Selection hits and deferred actions can cross live GUID incarnations
-**Status:** IN-PROGRESS — isolated as Slice 1 prerequisite hardening
+**Status:** FIXED (2026-07-21) — incarnation identity is carried and revalidated
**Severity:** HIGH
**Filed:** 2026-07-21
**Component:** selection / live entity lifetime / interaction
@@ -134,6 +134,13 @@ old click lighting never colors a replacement; natural MoveTo completion sends
only for the captured current visible incarnation; session reset clears the
complete interaction lifetime and never auto-targets.
+**Resolution:** Render parts/hits and SmartBox lighting now carry the local
+`WorldEntity.Id`; `LiveEntityRuntime` revalidates that identity against the
+current interaction-visible record. Deferred actions capture the same identity
+and teardown clears the captured action even after GUID replacement. Session
+reset now clears published selection geometry, lighting, ItemHolder target and
+throttle state, and cannot trigger combat auto-target acquisition.
+
---
## #228 — Clean Release build emits 17 test-project warnings
diff --git a/src/AcDream.App/Combat/CombatTargetController.cs b/src/AcDream.App/Combat/CombatTargetController.cs
index cdd739ac..4ce51f96 100644
--- a/src/AcDream.App/Combat/CombatTargetController.cs
+++ b/src/AcDream.App/Combat/CombatTargetController.cs
@@ -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;
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 9c8685e9..d5e07d95 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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)
diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs
new file mode 100644
index 00000000..7f3ef11c
--- /dev/null
+++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionGeometrySource.cs
@@ -0,0 +1,12 @@
+using AcDream.Core.Selection;
+
+namespace AcDream.App.Rendering.Selection;
+
+///
+/// Supplies the immutable DAT geometry used by retail's render-coupled picker.
+/// The seam keeps frame publication and incarnation tests independent of DAT IO.
+///
+internal interface IRetailSelectionGeometrySource
+{
+ RetailSelectionMesh? Resolve(uint gfxObjId);
+}
diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs
index d902ea7d..50779210 100644
--- a/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs
+++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionLightingSource.cs
@@ -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.
///
- bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting);
+ bool TryGetLighting(
+ uint serverGuid,
+ uint localEntityId,
+ out RetailSelectionLighting lighting);
}
diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs
index 270606cd..6e26ce73 100644
--- a/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs
+++ b/src/AcDream.App/Rendering/Selection/RetailSelectionGeometryCache.cs
@@ -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.
///
-internal sealed class RetailSelectionGeometryCache
+internal sealed class RetailSelectionGeometryCache : IRetailSelectionGeometrySource
{
private readonly IDatReaderWriter _dats;
private readonly object _datLock;
diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs
index a1300da9..cf28e950 100644
--- a/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs
+++ b/src/AcDream.App/Rendering/Selection/RetailSelectionLightingPulse.cs
@@ -27,6 +27,7 @@ internal sealed class RetailSelectionLightingPulse
private readonly Func _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? 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;
diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
index 8510be56..6a029d55 100644
--- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
+++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
@@ -11,7 +11,7 @@ namespace AcDream.App.Rendering.Selection;
///
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource
{
- private readonly RetailSelectionGeometryCache _geometry;
+ private readonly IRetailSelectionGeometrySource _geometry;
private readonly RetailSelectionLightingPulse _lightingPulse;
private List _building = new();
private List _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.
///
- 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);
+
+ /// Clears every session-owned frame and material pulse.
+ 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(
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index e258f252..cb230e04 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -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);
diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs
index 5ec1d1c9..39dbcd42 100644
--- a/src/AcDream.App/UI/ItemInteractionController.cs
+++ b/src/AcDream.App/UI/ItemInteractionController.cs
@@ -587,6 +587,24 @@ public sealed class ItemInteractionController : IDisposable
StateChanged?.Invoke();
}
+ ///
+ /// 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.
+ ///
+ 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();
diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs
index a7adbd29..65d8c7ad 100644
--- a/src/AcDream.App/World/LiveEntityRuntime.cs
+++ b/src/AcDream.App/World/LiveEntityRuntime.cs
@@ -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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
diff --git a/src/AcDream.Core/Selection/RetailSelectionMesh.cs b/src/AcDream.Core/Selection/RetailSelectionMesh.cs
index cccebf0c..0175f669 100644
--- a/src/AcDream.Core/Selection/RetailSelectionMesh.cs
+++ b/src/AcDream.Core/Selection/RetailSelectionMesh.cs
@@ -20,6 +20,7 @@ public sealed record RetailSelectionPolygon(
/// One part which survived the normal world-render visibility traversal.
public readonly record struct RetailSelectionPart(
uint ServerGuid,
+ uint LocalEntityId,
int PartIndex,
Matrix4x4 LocalToWorld,
RetailSelectionMesh Mesh);
@@ -27,6 +28,7 @@ public readonly record struct RetailSelectionPart(
/// Retail picker result, including which physics part supplied the hit.
public readonly record struct RetailSelectionHit(
uint ServerGuid,
+ uint LocalEntityId,
int PartIndex,
double Distance,
bool PolygonHit);
diff --git a/src/AcDream.Core/Selection/RetailWorldPicker.cs b/src/AcDream.Core/Selection/RetailWorldPicker.cs
index 794efa89..fd8004f9 100644
--- a/src/AcDream.Core/Selection/RetailWorldPicker.cs
+++ b/src/AcDream.Core/Selection/RetailWorldPicker.cs
@@ -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;
}
}
diff --git a/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs b/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs
index 1ef586bf..7a92e3e6 100644
--- a/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs
+++ b/tests/AcDream.App.Tests/Combat/CombatTargetControllerTests.cs
@@ -79,4 +79,21 @@ public sealed class CombatTargetControllerTests
Assert.Equal(0x50000002u, selection.SelectedObjectId);
}
+
+ [Fact]
+ public void SessionReset_DoesNotAcquireTargetFromDepartingWorld()
+ {
+ var combat = new CombatState();
+ combat.SetCombatMode(CombatMode.Missile);
+ var selection = new SelectionState();
+ selection.Select(0x50000002u, SelectionChangeSource.World);
+ int calls = 0;
+ using var controller = new CombatTargetController(
+ combat, selection, () => true, () => { calls++; return null; });
+
+ selection.Reset();
+
+ Assert.Equal(0, calls);
+ Assert.Null(selection.SelectedObjectId);
+ }
}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs
index 8ead7b21..9a670abb 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionLightingPulseTests.cs
@@ -10,28 +10,28 @@ public sealed class RetailSelectionLightingPulseTests
double now = 10d;
var pulse = new RetailSelectionLightingPulse(() => now);
- pulse.Start(0x5000_1234u);
- AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
+ pulse.Start(0x5000_1234u, 1234u);
+ AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High);
now = 10.199;
pulse.Tick();
- AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
+ AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High);
now = 10.2;
pulse.Tick();
- AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
+ AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low);
now = 10.4;
pulse.Tick();
- AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
+ AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.High);
now = 10.6;
pulse.Tick();
- AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
+ AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low);
now = 10.8;
pulse.Tick();
- Assert.False(pulse.TryGet(0x5000_1234u, out _));
+ Assert.False(pulse.TryGet(0x5000_1234u, 1234u, out _));
}
[Fact]
@@ -39,12 +39,12 @@ public sealed class RetailSelectionLightingPulseTests
{
double now = 1d;
var pulse = new RetailSelectionLightingPulse(() => now);
- pulse.Start(0x5000_1234u);
+ pulse.Start(0x5000_1234u, 1234u);
now = 20d;
pulse.Tick();
- AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
+ AssertLighting(pulse, 0x5000_1234u, 1234u, RetailSelectionLighting.Low);
}
[Fact]
@@ -52,24 +52,35 @@ public sealed class RetailSelectionLightingPulseTests
{
double now = 4d;
var pulse = new RetailSelectionLightingPulse(() => now);
- pulse.Start(0x5000_0001u);
+ pulse.Start(0x5000_0001u, 1u);
now = 4.2;
pulse.Tick();
- AssertLighting(pulse, 0x5000_0001u, RetailSelectionLighting.Low);
+ AssertLighting(pulse, 0x5000_0001u, 1u, RetailSelectionLighting.Low);
- pulse.Start(0x5000_0002u);
+ pulse.Start(0x5000_0002u, 2u);
- Assert.False(pulse.TryGet(0x5000_0001u, out _));
- AssertLighting(pulse, 0x5000_0002u, RetailSelectionLighting.High);
+ Assert.False(pulse.TryGet(0x5000_0001u, 1u, out _));
+ AssertLighting(pulse, 0x5000_0002u, 2u, RetailSelectionLighting.High);
+ }
+
+ [Fact]
+ public void GuidReuseDoesNotTransferPulseToReplacementIncarnation()
+ {
+ var pulse = new RetailSelectionLightingPulse(() => 1d);
+ pulse.Start(0x5000_0001u, 10u);
+
+ Assert.False(pulse.TryGet(0x5000_0001u, 11u, out _));
+ AssertLighting(pulse, 0x5000_0001u, 10u, RetailSelectionLighting.High);
}
private static void AssertLighting(
RetailSelectionLightingPulse pulse,
uint serverGuid,
+ uint localEntityId,
RetailSelectionLighting expected)
{
- Assert.True(pulse.TryGet(serverGuid, out var actual));
+ Assert.True(pulse.TryGet(serverGuid, localEntityId, out var actual));
Assert.Equal(expected, actual);
}
}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs
new file mode 100644
index 00000000..9fb24950
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs
@@ -0,0 +1,114 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Selection;
+using AcDream.Core.Selection;
+using AcDream.Core.World;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class RetailSelectionSceneTests
+{
+ private sealed class GeometrySource(RetailSelectionMesh mesh)
+ : IRetailSelectionGeometrySource
+ {
+ public RetailSelectionMesh? Resolve(uint gfxObjId)
+ => gfxObjId == 0x0100_0001u ? mesh : null;
+ }
+
+ [Fact]
+ public void PublishedHitCarriesLogicalEntityIdentity()
+ {
+ var scene = CreateScene();
+ var entity = Entity(localId: 44u, serverGuid: 0x5000_0044u);
+ Publish(scene, entity);
+
+ RetailSelectionHit? hit = PickCenter(scene);
+
+ Assert.NotNull(hit);
+ Assert.Equal(entity.ServerGuid, hit.Value.ServerGuid);
+ Assert.Equal(entity.Id, hit.Value.LocalEntityId);
+ }
+
+ [Fact]
+ public void ResetClearsPublishedFrameAndLightingPulse()
+ {
+ double now = 1d;
+ var pulse = new RetailSelectionLightingPulse(() => now);
+ var scene = CreateScene(pulse);
+ var entity = Entity(localId: 45u, serverGuid: 0x5000_0045u);
+ Publish(scene, entity);
+ scene.BeginLightingPulse(entity.ServerGuid, entity.Id);
+
+ scene.Reset();
+
+ Assert.Null(PickCenter(scene));
+ Assert.False(scene.TryGetLighting(entity.ServerGuid, entity.Id, out _));
+ }
+
+ private static RetailSelectionScene CreateScene(
+ RetailSelectionLightingPulse? pulse = null)
+ {
+ var mesh = new RetailSelectionMesh(
+ Vector3.Zero,
+ 2f,
+ [new RetailSelectionPolygon(
+ [
+ new(-1f, -1f, 0f),
+ new( 1f, -1f, 0f),
+ new( 1f, 1f, 0f),
+ new(-1f, 1f, 0f),
+ ],
+ SingleSided: false)]);
+ return new RetailSelectionScene(new GeometrySource(mesh), pulse);
+ }
+
+ private static WorldEntity Entity(uint localId, uint serverGuid)
+ => new()
+ {
+ Id = localId,
+ ServerGuid = serverGuid,
+ SourceGfxObjOrSetupId = 0x0200_0001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = [],
+ };
+
+ private static void Publish(RetailSelectionScene scene, WorldEntity entity)
+ {
+ scene.BeginFrame();
+ (Matrix4x4 view, Matrix4x4 projection) = Camera();
+ scene.SetViewFrustum(FrustumPlanes.FromViewProjection(view * projection));
+ scene.AddVisiblePart(
+ entity,
+ partIndex: 0,
+ gfxObjId: 0x0100_0001u,
+ Matrix4x4.CreateTranslation(0f, 0f, -5f));
+ scene.CompleteFrame();
+ }
+
+ private static RetailSelectionHit? PickCenter(RetailSelectionScene scene)
+ {
+ (Matrix4x4 view, Matrix4x4 projection) = Camera();
+ return scene.Pick(
+ 400f,
+ 300f,
+ new Vector2(800f, 600f),
+ view,
+ projection,
+ skipServerGuid: 0u);
+ }
+
+ private static (Matrix4x4 View, Matrix4x4 Projection) Camera()
+ {
+ Matrix4x4 view = Matrix4x4.CreateLookAt(
+ Vector3.Zero,
+ -Vector3.UnitZ,
+ Vector3.UnitY);
+ Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI / 3f,
+ 4f / 3f,
+ 0.1f,
+ 100f);
+ return (view, projection);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
index 9622e244..58d2a4d6 100644
--- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
@@ -1418,4 +1418,20 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(0x50000A0Au));
Assert.Equal(2, h.Uses.Count);
}
+
+ [Fact]
+ public void ResetSession_ClearsTargetBusyThrottleAndConsumedClickState()
+ {
+ var h = new Harness();
+ const uint kit = 0x50000A0Cu;
+ h.AddContained(kit, item => item.Useability = HealthKitUseability);
+ Assert.True(h.Controller.ActivateItem(kit));
+ h.Controller.IncrementBusyCount();
+
+ h.Controller.ResetSession();
+
+ Assert.False(h.Controller.IsAnyTargetModeActive);
+ Assert.Equal(0, h.Controller.BusyCount);
+ Assert.Equal(InteractionModeKind.None, h.Controller.InteractionState.Current.Kind);
+ }
}
diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
index f0da5d2b..b578950f 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs
@@ -808,6 +808,9 @@ public sealed class LiveEntityRuntimeTests
guid, 0x01010001u, id => Entity(id, guid))!;
Assert.NotEqual(first.Id, second.Id);
+ Assert.False(runtime.TryGetInteractionEligibleRecord(guid, first.Id, out _));
+ Assert.True(runtime.TryGetInteractionEligibleRecord(guid, second.Id, out var current));
+ Assert.Same(second, current.WorldEntity);
Assert.Equal(2, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
@@ -1428,6 +1431,7 @@ public sealed class LiveEntityRuntimeTests
Assert.False(entity.IsDrawVisible);
Assert.Empty(runtime.WorldEntities);
Assert.False(runtime.TryGetInteractionEligibleEntity(guid, out _));
+ Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id, out _));
Assert.True(runtime.TryApplyState(new SetState.Parsed(
guid,
@@ -1437,6 +1441,9 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(RetailHiddenTransition.BecameVisible, visible.HiddenTransition);
Assert.True(entity.IsDrawVisible);
Assert.True(runtime.TryGetInteractionEligibleEntity(guid, out var eligible));
+ Assert.True(runtime.TryGetInteractionEligibleRecord(guid, entity.Id, out var eligibleRecord));
+ Assert.Same(entity, eligibleRecord.WorldEntity);
+ Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id + 1u, out _));
Assert.Same(entity, eligible);
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(entity, Assert.Single(spatial.Entities));
diff --git a/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs b/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs
index 9528c2cb..18d3c80a 100644
--- a/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs
+++ b/tests/AcDream.Core.Tests/Selection/RetailWorldPickerTests.cs
@@ -30,6 +30,7 @@ public sealed class RetailWorldPickerTests
Assert.NotNull(hit);
Assert.Equal(2u, hit.Value.ServerGuid);
+ Assert.Equal(1002u, hit.Value.LocalEntityId);
Assert.True(hit.Value.PolygonHit);
}
@@ -72,6 +73,7 @@ public sealed class RetailWorldPickerTests
{
var front = new RetailSelectionPart(
1u,
+ 1001u,
0,
Matrix4x4.Identity,
new RetailSelectionMesh(
@@ -92,6 +94,7 @@ public sealed class RetailWorldPickerTests
* Matrix4x4.CreateTranslation(0f, 0f, -10f);
var part = new RetailSelectionPart(
3u,
+ 1003u,
0,
transform,
new RetailSelectionMesh(Vector3.Zero, 1f, [SquareAtLocalZ(0f)]));
@@ -152,6 +155,7 @@ public sealed class RetailWorldPickerTests
float sphereRadius = 2f)
=> new(
guid,
+ guid + 1000u,
0,
Matrix4x4.CreateTranslation(0f, 0f, z),
new RetailSelectionMesh(