diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 281e6978..9c8685e9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2264,7 +2264,7 @@ public sealed class GameWindow : IDisposable coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, uiLocked: () => _persistedGameplay.LockUI, playerEntities: () => _entitiesByServerGuid, - copySpatialCandidates: _worldState.CopyLiveEntitiesNearLandblock); + spatialQuery: () => _worldState); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); _retailChatVm = retailChatVm; AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index f1e004b6..23a05e2f 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Numerics; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; +using AcDream.App.World; using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -39,7 +40,7 @@ namespace AcDream.App.Streaming; /// Threading: not thread-safe. All calls must happen on the render thread. /// /// -public sealed class GpuWorldState +public sealed class GpuWorldState : ILiveEntitySpatialQuery { private readonly LandblockSpawnAdapter? _wbSpawnAdapter; private readonly EntityScriptActivator? _entityScriptActivator; diff --git a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs index a4d714d1..96cafad2 100644 --- a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs +++ b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Physics.Motion; @@ -29,7 +30,7 @@ public sealed class RadarSnapshotProvider private readonly Func _coordinatesOnRadar; private readonly Func _uiLocked; private readonly Func? _relationshipFor; - private readonly Action>>? _copySpatialCandidates; + private readonly Func? _spatialQuery; private readonly List> _candidateScratch = new(); public RadarSnapshotProvider( @@ -44,7 +45,7 @@ public sealed class RadarSnapshotProvider Func uiLocked, Func? relationshipFor = null, Func>? playerEntities = null, - Action>>? copySpatialCandidates = null) + Func? spatialQuery = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities)); @@ -57,7 +58,7 @@ public sealed class RadarSnapshotProvider _coordinatesOnRadar = coordinatesOnRadar ?? throw new ArgumentNullException(nameof(coordinatesOnRadar)); _uiLocked = uiLocked ?? throw new ArgumentNullException(nameof(uiLocked)); _relationshipFor = relationshipFor; - _copySpatialCandidates = copySpatialCandidates; + _spatialQuery = spatialQuery; } public UiRadarSnapshot BuildSnapshot() @@ -90,12 +91,16 @@ public sealed class RadarSnapshotProvider float range = RetailRadar.GetRangeMeters(isOutside); _candidateScratch.Clear(); - if (_copySpatialCandidates is not null) + // Retained UI mounts before the production GpuWorldState. Resolve the + // spatial owner per snapshot just like the live dictionaries above; + // retaining a method group here binds forever to the empty bootstrap + // state and produces a working compass with no object blips. + if (_spatialQuery?.Invoke() is { } spatialQuery) { // Retail radar is at most 75 metres while a landblock is 192 // metres wide. The current block plus its eight neighbours is a // complete broadphase (the cheap spatial pre-filter). - _copySpatialCandidates(playerCellId, 1, _candidateScratch); + spatialQuery.CopyLiveEntitiesNearLandblock(playerCellId, 1, _candidateScratch); } else { diff --git a/src/AcDream.App/World/ILiveEntitySpatialQuery.cs b/src/AcDream.App/World/ILiveEntitySpatialQuery.cs new file mode 100644 index 00000000..1b587267 --- /dev/null +++ b/src/AcDream.App/World/ILiveEntitySpatialQuery.cs @@ -0,0 +1,16 @@ +using AcDream.Core.World; + +namespace AcDream.App.World; + +/// +/// Read-only spatial broadphase for live server-object projections. +/// Consumers resolve this owner late because GameWindow replaces its +/// empty bootstrap world state after retained UI construction. +/// +public interface ILiveEntitySpatialQuery +{ + void CopyLiveEntitiesNearLandblock( + uint centerCellOrLandblockId, + int landblockRadius, + List> destination); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs index 16f5957c..393c682a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; @@ -210,8 +211,8 @@ public sealed class RadarSnapshotProviderTests [unrelated] = Entity(unrelated, new Vector3(6f, 0f, 0f), Quaternion.Identity), }; var spawns = entities.Keys.ToDictionary(guid => guid, Spawn); - uint copiedCell = 0; - int copiedRadius = -1; + var spatialQuery = new RecordingSpatialQuery( + new KeyValuePair(nearby, entities[nearby])); var provider = new RadarSnapshotProvider( objects, () => entities, @@ -222,20 +223,70 @@ public sealed class RadarSnapshotProviderTests selectedGuid: () => null, coordinatesOnRadar: () => true, uiLocked: () => false, - copySpatialCandidates: (cell, radius, destination) => - { - copiedCell = cell; - copiedRadius = radius; - destination.Add(new KeyValuePair(nearby, entities[nearby])); - }); + spatialQuery: () => spatialQuery); UiRadarSnapshot snapshot = provider.BuildSnapshot(); - Assert.Equal(0xA9B40001u, copiedCell); - Assert.Equal(1, copiedRadius); + Assert.Equal(0xA9B40001u, spatialQuery.CopiedCell); + Assert.Equal(1, spatialQuery.CopiedRadius); Assert.Equal(nearby, Assert.Single(snapshot.Blips).ObjectId); } + [Fact] + public void BuildSnapshot_ResolvesSpatialOwnerAfterBootstrapReplacement() + { + const uint player = 50u; + const uint monster = 51u; + var objects = new ClientObjectTable(); + objects.Ingest(Weenie(player, "Player", ItemType.Creature)); + objects.Ingest(Weenie(monster, "Drudge", ItemType.Creature) with + { + RadarBehavior = (byte)RadarBehavior.ShowAlways, + }); + var entities = new Dictionary + { + [player] = Entity(player, Vector3.Zero, Quaternion.Identity), + [monster] = Entity(monster, new Vector3(5f, 0f, 0f), Quaternion.Identity), + }; + var spawns = entities.Keys.ToDictionary(guid => guid, Spawn); + ILiveEntitySpatialQuery currentSpatialQuery = new RecordingSpatialQuery(); + var provider = new RadarSnapshotProvider( + objects, + () => entities, + () => spawns, + playerGuid: () => player, + playerYawRadians: () => 0f, + playerCellId: () => 0xA9B40001u, + selectedGuid: () => null, + coordinatesOnRadar: () => true, + uiLocked: () => false, + spatialQuery: () => currentSpatialQuery); + + Assert.Empty(provider.BuildSnapshot().Blips); + + currentSpatialQuery = new RecordingSpatialQuery( + new KeyValuePair(monster, entities[monster])); + + Assert.Equal(monster, Assert.Single(provider.BuildSnapshot().Blips).ObjectId); + } + + private sealed class RecordingSpatialQuery( + params KeyValuePair[] candidates) : ILiveEntitySpatialQuery + { + public uint CopiedCell { get; private set; } + public int CopiedRadius { get; private set; } = -1; + + public void CopyLiveEntitiesNearLandblock( + uint centerCellOrLandblockId, + int landblockRadius, + List> destination) + { + CopiedCell = centerCellOrLandblockId; + CopiedRadius = landblockRadius; + destination.AddRange(candidates); + } + } + private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new() { Id = guid,