feat(selection): port retail polygon picking and vivid marker

Replace the projected Setup-sphere rectangle and independent physics-wall ray with retail's render-coupled picker: only visible server-object parts participate, each exact drawing sphere broad-phases the camera-eye ray, and first-in-DAT-order visual polygon hits globally outrank sphere fallbacks.

Replace the devtools-only procedural triangles with the retained gameplay VividTargetIndicator using retail client-enum surfaces 1..4, radar-blip colorization, Setup selection-sphere framing, and the exact eight-pixel viewport clamp.

Release build succeeds with zero warnings and all 5,886 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 21:32:51 +02:00
parent 0f82a08f0a
commit 146a963aeb
26 changed files with 1302 additions and 1340 deletions

View file

@ -0,0 +1,117 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection;
using AcDream.App.UI;
using AcDream.Core.Textures;
using AcDream.Core.Selection;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit.Sdk;
using SysEnv = System.Environment;
namespace AcDream.App.Tests.Rendering;
public sealed class RetailSelectionAssetTests
{
[Fact]
public void InstalledDat_ResolvesAllFourVividTargetCorners()
{
string datDir = ResolveDatDir();
using var dats = new DatCollection(datDir, DatAccessType.Read);
var master = Assert.IsType<EnumIDMap>(
dats.Get<EnumIDMap>((uint)dats.Portal.Header.MasterMapId));
uint subMapDid = master.ClientEnumToID[0x10000009u];
var subMap = Assert.IsType<EnumIDMap>(dats.Get<EnumIDMap>(subMapDid));
for (uint index = 0; index < 4; index++)
{
uint enumValue = 1u + index;
uint did = RetailDataIdResolver.Resolve(dats, enumValue, 0x10000009u);
Assert.True(did != 0u,
$"enum {enumValue}; submap 0x{subMapDid:X8} keys: {string.Join(',', subMap.ClientEnumToID.Keys.Select(k => $"0x{k:X8}"))}");
Assert.True(dats.Portal.TryGet<RenderSurface>(did, out var surface)
|| dats.HighRes.TryGet<RenderSurface>(did, out surface),
$"enum 0x{enumValue:X8} resolved 0x{did:X8}");
Assert.NotNull(surface);
Assert.Equal(0x06000000u, did & 0xFF000000u);
Assert.Equal((12, 12), (surface.Width, surface.Height));
Palette? palette = surface.DefaultPaletteId != 0
? dats.Get<Palette>(surface.DefaultPaletteId)
: null;
DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(surface, palette);
int coloredPixels = 0;
for (int pixel = 0; pixel < decoded.Rgba8.Length; pixel += 4)
{
if (decoded.Rgba8[pixel + 3] == 0)
continue;
if (decoded.Rgba8[pixel] != decoded.Rgba8[pixel + 1]
|| decoded.Rgba8[pixel] != decoded.Rgba8[pixel + 2])
coloredPixels++;
}
Assert.True(coloredPixels == 0,
$"corner {index + 1} has {coloredPixels} non-grayscale source pixels");
}
}
[Fact]
public void DoorSelectionGeometry_UsesDrawingBspRootSphereAndDatPolygonOrder()
{
const uint doorGfxObjId = 0x010044B5u;
string datDir = ResolveDatDir();
using var dats = new DatCollection(datDir, DatAccessType.Read);
var gfx = Assert.IsType<GfxObj>(dats.Get<GfxObj>(doorGfxObjId));
var cache = new RetailSelectionGeometryCache(dats, new object());
var selection = Assert.IsType<AcDream.Core.Selection.RetailSelectionMesh>(
cache.Resolve(doorGfxObjId));
var root = Assert.IsType<DatReaderWriter.Types.DrawingBSPNode>(gfx.DrawingBSP.Root);
Assert.Equal(root.BoundingSphere.Origin, selection.SphereCenter);
Assert.Equal(root.BoundingSphere.Radius, selection.SphereRadius);
Assert.Equal(gfx.Polygons.Count, selection.Polygons.Count);
var firstSource = gfx.Polygons.First().Value;
var firstSelection = selection.Polygons[0];
Assert.Equal((int)firstSource.SidesType == 0, firstSelection.SingleSided);
Assert.Equal(firstSource.VertexIds.Count, firstSelection.Vertices.Count);
}
[Fact]
public void DrawingSphereViewconeRejectsPartBehindCamera()
{
Matrix4x4 view = Matrix4x4.CreateLookAt(
Vector3.Zero, -Vector3.UnitZ, Vector3.UnitY);
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 3f, 4f / 3f, 0.1f, 100f);
FrustumPlanes frustum = FrustumPlanes.FromViewProjection(view * projection);
var mesh = new RetailSelectionMesh(Vector3.Zero, 1f, []);
Assert.True(RetailSelectionScene.DrawingSphereIntersectsFrustum(
mesh, Matrix4x4.CreateTranslation(0f, 0f, -5f), frustum));
Assert.False(RetailSelectionScene.DrawingSphereIntersectsFrustum(
mesh, Matrix4x4.CreateTranslation(0f, 0f, 5f), frustum));
}
private static string ResolveDatDir()
{
string? configured = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(configured)
&& File.Exists(Path.Combine(configured, "client_portal.dat")))
return configured;
const string installed = @"C:\Turbine\Asheron's Call";
if (File.Exists(Path.Combine(installed, "client_portal.dat")))
return installed;
string conventional = Path.Combine(
SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
if (File.Exists(Path.Combine(conventional, "client_portal.dat")))
return conventional;
throw SkipException.ForSkip("Installed client_portal.dat is required.");
}
}

View file

@ -0,0 +1,40 @@
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class VividTargetIndicatorLayoutTests
{
private static readonly (float Width, float Height)[] RetailCorners =
[(12f, 12f), (12f, 12f), (12f, 12f), (12f, 12f)];
[Fact]
public void CornersSitOutsideSelectionSphereRectangle()
{
VividTargetLayout layout = VividTargetIndicatorController.ComputeLayout(
new Vector2(100f, 80f),
new Vector2(180f, 200f),
new Vector2(800f, 600f),
RetailCorners);
Assert.Equal(new Vector2(88f, 68f), layout.RootPosition);
Assert.Equal(new Vector2(104f, 144f), layout.RootSize);
Assert.Equal(Vector2.Zero, layout.CornerPositions[0]);
Assert.Equal(new Vector2(92f, 0f), layout.CornerPositions[1]);
Assert.Equal(new Vector2(92f, 132f), layout.CornerPositions[2]);
Assert.Equal(new Vector2(0f, 132f), layout.CornerPositions[3]);
}
[Fact]
public void WholeIndicatorClampsToRetailEightPixelViewportMargin()
{
VividTargetLayout layout = VividTargetIndicatorController.ComputeLayout(
new Vector2(2f, 3f),
new Vector2(62f, 83f),
new Vector2(200f, 150f),
RetailCorners);
Assert.Equal(new Vector2(8f, 8f), layout.RootPosition);
Assert.Equal(new Vector2(66f, 87f), layout.RootSize);
}
}

View file

@ -1,86 +0,0 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using DatReaderWriter.Enums;
using Xunit;
namespace AcDream.Core.Tests.Selection;
public class CellBspRayOccluderTests
{
// Build a CellPhysics with a single triangular poly at world-Y=10.
// Triangle vertices in local space, world transform = identity.
// Uses the Resolved-only constructor path (BSP = null is allowed after Phase 1 relaxation).
private static CellPhysics MakeWallCell()
{
var verts = new[]
{
new Vector3(-5, 10, 0),
new Vector3( 5, 10, 0),
new Vector3( 0, 10, 5),
};
var poly = new ResolvedPolygon
{
Vertices = verts,
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
NumPoints = 3,
SidesType = CullMode.None,
};
return new CellPhysics
{
BSP = null, // Occluder doesn't use BSP — direct poly iteration.
Resolved = new() { [0] = poly },
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
[Fact]
public void NearestWallT_RayHitsTriangle_ReturnsHitDistance()
{
var cell = MakeWallCell();
var origin = new Vector3(0, 0, 1);
var direction = Vector3.UnitY; // travels +Y toward the wall at Y=10
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}");
}
[Fact]
public void NearestWallT_RayMisses_ReturnsPositiveInfinity()
{
var cell = MakeWallCell();
var origin = new Vector3(0, 0, 1);
var direction = -Vector3.UnitY; // travels AWAY from the wall
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}");
}
[Fact]
public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity()
{
var origin = Vector3.Zero;
var direction = Vector3.UnitY;
float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty<CellPhysics>());
Assert.True(float.IsPositiveInfinity(t));
}
[Fact]
public void NearestWallT_TwoCells_ReturnsNearer()
{
var nearCell = MakeWallCell(); // wall at Y=10
var farCell = MakeWallCell();
// Move farCell's transform to push it to Y=20.
farCell = new CellPhysics
{
BSP = null,
Resolved = nearCell.Resolved,
WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0),
InverseWorldTransform = Matrix4x4.CreateTranslation(0, -10, 0),
};
var origin = new Vector3(0, 0, 1);
var direction = Vector3.UnitY;
float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
}
}

View file

@ -0,0 +1,173 @@
using System.Numerics;
using AcDream.Core.Selection;
namespace AcDream.Core.Tests.Selection;
public sealed class RetailWorldPickerTests
{
[Fact]
public void BuildRayStartsAtCameraViewpointRatherThanNearPlane()
{
var eye = new Vector3(10f, 20f, 30f);
Matrix4x4 view = Matrix4x4.CreateLookAt(eye, eye - Vector3.UnitZ, Vector3.UnitY);
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 3f, 4f / 3f, 0.1f, 1000f);
var ray = WorldPicker.BuildRay(400f, 300f, 800f, 600f, view, projection);
Assert.True(Vector3.Distance(eye, ray.Origin) < 0.0001f);
Assert.True(Vector3.Distance(-Vector3.UnitZ, ray.Direction) < 0.0001f);
}
[Fact]
public void PolygonHitGloballyBeatsNearerSphereFallback()
{
var sphereOnly = Part(1u, z: -3f, polygons: []);
var polygon = Part(2u, z: -8f, polygons: [SquareAtLocalZ(0f)]);
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero, -Vector3.UnitZ, [sphereOnly, polygon]);
Assert.NotNull(hit);
Assert.Equal(2u, hit.Value.ServerGuid);
Assert.True(hit.Value.PolygonHit);
}
[Fact]
public void SphereIsUsedOnlyWhenNoVisiblePolygonHits()
{
var part = Part(0xABCDu, z: -5f, polygons: []);
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero, -Vector3.UnitZ, [part]);
Assert.NotNull(hit);
Assert.Equal(0xABCDu, hit.Value.ServerGuid);
Assert.False(hit.Value.PolygonHit);
Assert.Equal(3d, hit.Value.Distance, 4);
}
[Fact]
public void StopsAtFirstHitPolygonInDatOrderForEachPart()
{
var datOrdered = Part(
10u,
z: 0f,
polygons: [SquareAtLocalZ(-10f), SquareAtLocalZ(-5f)],
sphereCenter: new Vector3(0f, 0f, -7.5f),
sphereRadius: 5f);
var competitor = Part(20u, z: -7f, polygons: [SquareAtLocalZ(0f)]);
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero, -Vector3.UnitZ, [datOrdered, competitor]);
// Scanning all polygons would incorrectly give guid 10 at 5 m. Retail
// accepts its first DAT-order hit at 10 m, so guid 20 wins at 7 m.
Assert.NotNull(hit);
Assert.Equal(20u, hit.Value.ServerGuid);
}
[Fact]
public void SingleSidedPolygonRejectsItsBackFace()
{
var front = new RetailSelectionPart(
1u,
0,
Matrix4x4.Identity,
new RetailSelectionMesh(
new Vector3(0f, 0f, -5f), 2f,
[SquareAtLocalZ(-5f, singleSided: true)]));
RetailSelectionHit? backHit = RetailWorldPicker.Pick(
new Vector3(0f, 0f, -10f), Vector3.UnitZ, [front]);
Assert.NotNull(backHit); // drawing sphere remains the retail fallback
Assert.False(backHit.Value.PolygonHit);
}
[Fact]
public void InverseScaledPartPreservesWorldDistance()
{
var transform = Matrix4x4.CreateScale(2f)
* Matrix4x4.CreateTranslation(0f, 0f, -10f);
var part = new RetailSelectionPart(
3u,
0,
transform,
new RetailSelectionMesh(Vector3.Zero, 1f, [SquareAtLocalZ(0f)]));
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero, -Vector3.UnitZ, [part]);
Assert.NotNull(hit);
Assert.True(hit.Value.PolygonHit);
Assert.Equal(10d, hit.Value.Distance, 4);
}
[Fact]
public void SkipGuidAndNonServerPartsNeverCompete()
{
RetailSelectionHit? hit = RetailWorldPicker.Pick(
Vector3.Zero,
-Vector3.UnitZ,
[Part(0u, -2f, [SquareAtLocalZ(0f)]), Part(7u, -4f, [SquareAtLocalZ(0f)])],
skipServerGuid: 7u);
Assert.Null(hit);
}
[Fact]
public void DrawingSphereRejectsRayWhichBeginsInsideLikeRetail()
{
bool hit = RetailWorldPicker.TryIntersectSphere(
Vector3.Zero, Vector3.UnitX, Vector3.Zero, 2f, out _);
Assert.False(hit);
}
[Fact]
public void ConvexEdgeTestRejectsAConcaveNotchRatherThanUsingEvenOddFill()
{
var concave = new RetailSelectionPolygon(
[
new(-2f, -2f, -5f),
new( 2f, -2f, -5f),
new( 2f, 2f, -5f),
new( 0f, 0f, -5f),
new(-2f, 2f, -5f),
],
SingleSided: false);
bool hit = RetailWorldPicker.TryIntersectPolygon(
new Vector3(0f, 1f, 0f), -Vector3.UnitZ, concave, out _);
Assert.False(hit);
}
private static RetailSelectionPart Part(
uint guid,
float z,
IReadOnlyList<RetailSelectionPolygon> polygons,
Vector3? sphereCenter = null,
float sphereRadius = 2f)
=> new(
guid,
0,
Matrix4x4.CreateTranslation(0f, 0f, z),
new RetailSelectionMesh(
sphereCenter ?? Vector3.Zero,
sphereRadius,
polygons));
private static RetailSelectionPolygon SquareAtLocalZ(
float z,
bool singleSided = false)
=> new(
[
new Vector3(-1f, -1f, z),
new Vector3( 1f, -1f, z),
new Vector3( 1f, 1f, z),
new Vector3(-1f, 1f, z),
],
singleSided);
}

View file

@ -1,189 +0,0 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using DatReaderWriter.Enums;
using Xunit;
namespace AcDream.Core.Tests.Selection;
public class WorldPickerCellOcclusionTests
{
private static CellPhysics MakeWallAtY10()
{
// A quad wall at Y=10 spanning X=-5..5, Z=-5..5 (local space = world space
// because WorldTransform = Identity). The occluder triangulates it as a fan:
// tri0 = [0,1,2], tri1 = [0,2,3]. A ray travelling +Y from Y=0 hits it at t≈10.
var verts = new[]
{
new Vector3(-5, 10, -5),
new Vector3( 5, 10, -5),
new Vector3( 5, 10, 5),
new Vector3(-5, 10, 5),
};
var poly = new ResolvedPolygon
{
Vertices = verts,
Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
NumPoints = 4,
SidesType = CullMode.None,
};
return new CellPhysics
{
BSP = null,
Resolved = new() { [0] = poly },
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new()
{
Id = guid,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0,
Position = pos,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
/// <summary>
/// Builds a quad wall at Z=-10 in front of the camera (identity view,
/// camera looking down -Z). The wall spans X=-5..5, Y=-5..5 at Z=-10 —
/// large enough to cover the center-pixel ray. An entity at Z=-20 sits
/// behind it.
///
/// Wall normal direction doesn't affect Möller-Trumbore (the occluder
/// is two-sided), but the Plane is stored for completeness. For a plane
/// at z=-10 with outward normal (0,0,+1): (0,0,1)·(x,y,-10) + D = 0
/// → D = 10.
/// </summary>
private static CellPhysics MakeWallAtZNeg10()
{
var verts = new[]
{
new Vector3(-5, -5, -10),
new Vector3( 5, -5, -10),
new Vector3( 5, 5, -10),
new Vector3(-5, 5, -10),
};
var poly = new ResolvedPolygon
{
Vertices = verts,
Plane = new System.Numerics.Plane(new Vector3(0, 0, 1), 10f),
NumPoints = 4,
SidesType = CullMode.None,
};
return new CellPhysics
{
BSP = null,
Resolved = new() { [0] = poly },
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
// ──────────────────────────────────────────────
// Screen-rect overload + cell-BSP occlusion
// ──────────────────────────────────────────────
/// <summary>
/// Production path exercised by GameWindow.PickAndStoreSelection.
/// Camera at origin looking down -Z (identity view). Entity at Z=-20
/// projects to the center of the viewport. A wall at Z=-10 sits between
/// camera and entity; with cellOccluder wired up the entity must be
/// occluded → null result.
///
/// This test specifically covers the clip.W depth-conversion math in
/// WorldPicker.Pick's screen-rect overload (issue #86).
/// </summary>
[Fact]
public void Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp()
{
// Use the same camera convention as WorldPickerRectOverloadTests.StdCam():
// identity view, 90-degree FoV, 800×600 viewport. Center pixel = (400,300).
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
var viewport = new Vector2(800f, 600f);
var wall = MakeWallAtZNeg10();
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20));
// Entity is dead-ahead: center of viewport.
var result = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
candidates: new[] { entity },
skipServerGuid: 0u,
sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f),
inflatePixels: 8f,
cellOccluder: (origin, direction) =>
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
Assert.Null(result);
}
/// <summary>
/// Same camera and entity as Pick_ScreenRect_EntityBehindWall_OccludedByCellBsp,
/// but with a null cellOccluder. Verifies that the no-occluder path still
/// resolves the entity to a hit (the new parameter is a pure no-op when null).
/// </summary>
[Fact]
public void Pick_ScreenRect_NoWall_HitsEntity()
{
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
var viewport = new Vector2(800f, 600f);
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -20));
var result = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
candidates: new[] { entity },
skipServerGuid: 0u,
sphereForEntity: e => ((Vector3, float)?)(e.Position, 1.0f),
inflatePixels: 8f,
cellOccluder: null);
Assert.Equal(0xABCDu, result);
}
// ──────────────────────────────────────────────
// Ray-sphere overload (legacy path)
// ──────────────────────────────────────────────
[Fact]
public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp()
{
var wall = MakeWallAtY10();
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); // entity at Y=20, wall at Y=10
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: Vector3.UnitY,
candidates: new[] { entity },
skipServerGuid: 0u,
cellOccluder: (origin, direction) =>
CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
Assert.Null(result);
}
[Fact]
public void Pick_RaySphere_NoWall_HitsEntity()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: Vector3.UnitY,
candidates: new[] { entity },
skipServerGuid: 0u,
cellOccluder: null); // null occluder = no occlusion
Assert.Equal(0xABCDu, result);
}
}

View file

@ -1,133 +0,0 @@
using System;
using System.Numerics;
using AcDream.Core.Selection;
using AcDream.Core.World;
namespace AcDream.Core.Tests.Selection;
public sealed class WorldPickerRectOverloadTests
{
private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
{
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
var viewport = new Vector2(800, 600);
return (view, proj, viewport);
}
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
{
Id = serverGuid == 0u ? 1u : serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void Pick_RectHitTest_ReturnsHitWhenMouseInsideRect()
{
var (view, proj, viewport) = StdCam();
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
uint? picked = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { e },
skipServerGuid: 0u,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10001u, picked);
}
[Fact]
public void Pick_RectHitTest_ReturnsNullWhenMouseOutsideRect()
{
var (view, proj, viewport) = StdCam();
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
uint? picked = WorldPicker.Pick(
mouseX: 50f, mouseY: 50f,
view, proj, viewport,
new[] { e },
skipServerGuid: 0u,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Null(picked);
}
[Fact]
public void Pick_RectHitTest_PicksNearerWhenRectsOverlap()
{
var (view, proj, viewport) = StdCam();
var near = MakeEntity(0x10001u, new Vector3(0, 0, -8));
var far = MakeEntity(0x10002u, new Vector3(0, 0, -15));
uint? picked = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { far, near } /* deliberately reversed */,
skipServerGuid: 0u,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10001u, picked);
}
[Fact]
public void Pick_RectHitTest_NullResolverSkipsCandidates()
{
var (view, proj, viewport) = StdCam();
var e1 = MakeEntity(0x10001u, new Vector3(0, 0, -10));
var e2 = MakeEntity(0x10002u, new Vector3(0, 0, -20));
uint? picked = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { e1, e2 },
skipServerGuid: 0u,
sphereForEntity: x => x.ServerGuid == 0x10001u
? ((Vector3, float)?)null
: ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10002u, picked);
}
[Fact]
public void Pick_RectHitTest_RespectsSkipServerGuid()
{
var (view, proj, viewport) = StdCam();
var player = MakeEntity(0x5000000Au, new Vector3(0, 0, -10));
var npc = MakeEntity(0x10002u, new Vector3(0, 0, -15));
uint? picked = WorldPicker.Pick(
mouseX: 400f, mouseY: 300f,
view, proj, viewport,
new[] { player, npc },
skipServerGuid: 0x5000000Au,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Equal(0x10002u, picked);
}
[Fact]
public void Pick_RectHitTest_InflateExpandsClickableArea()
{
var (view, proj, viewport) = StdCam();
var e = MakeEntity(0x10001u, new Vector3(0, 0, -10));
uint? withoutInflate = WorldPicker.Pick(
mouseX: 400f + 200f, mouseY: 300f,
view, proj, viewport,
new[] { e },
skipServerGuid: 0u,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 0f);
Assert.Null(withoutInflate);
uint? withInflate = WorldPicker.Pick(
mouseX: 400f + 200f, mouseY: 300f,
view, proj, viewport,
new[] { e },
skipServerGuid: 0u,
sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
inflatePixels: 250f);
Assert.Equal(0x10001u, withInflate);
}
}

View file

@ -1,169 +0,0 @@
using System;
using System.Numerics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Selection;
public class WorldPickerTests
{
private const float Epsilon = 0.01f;
private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera()
{
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
fieldOfView: MathF.PI / 3f,
aspectRatio: 16f / 9f,
nearPlaneDistance: 0.1f,
farPlaneDistance: 100f);
return (view, proj);
}
[Fact]
public void BuildRay_CenterOfViewport_ReturnsForwardRay()
{
var (view, proj) = MakeIdentityCamera();
const float vpW = 1920f, vpH = 1080f;
var (_, direction) = WorldPicker.BuildRay(
mouseX: vpW / 2f, mouseY: vpH / 2f,
viewportW: vpW, viewportH: vpH,
view, proj);
// Right-handed perspective + identity view -> camera looks down -Z.
// Center pixel ray = (0, 0, -1) within float epsilon.
Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}");
Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}");
Assert.True(direction.Z < -0.99f, $"direction.Z = {direction.Z}");
}
[Fact]
public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX()
{
var (view, proj) = MakeIdentityCamera();
const float vpW = 1920f, vpH = 1080f;
var (_, direction) = WorldPicker.BuildRay(
mouseX: vpW * 0.75f, mouseY: vpH / 2f,
viewportW: vpW, viewportH: vpH,
view, proj);
Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
}
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
{
Id = serverGuid == 0u ? 1u : serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void Pick_RayThroughEntity_ReturnsServerGuid()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Equal(0xABCDu, result);
}
[Fact]
public void Pick_RayMisses_ReturnsNull()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: Vector3.UnitX,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Null(result);
}
[Fact]
public void Pick_TwoEntitiesInLine_ReturnsCloser()
{
var near = MakeEntity(0x1111u, new Vector3(0, 0, -5));
var far = MakeEntity(0x2222u, new Vector3(0, 0, -20));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { far, near }, // iteration order shouldn't matter
skipServerGuid: 0u);
Assert.Equal(0x1111u, result);
}
[Fact]
public void Pick_SkipsSkipGuid()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0xABCDu);
Assert.Null(result);
}
[Fact]
public void Pick_SkipsZeroServerGuid()
{
// Atlas-tier scenery / dat-hydrated statics carry ServerGuid=0
// and aren't valid Use targets — server would reject guid=0.
var entity = MakeEntity(0u, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0xDEADu);
Assert.Null(result);
}
[Fact]
public void Pick_BeyondMaxDistance_ReturnsNull()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -100));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u); // default maxDistance = 50f
Assert.Null(result);
}
[Fact]
public void Pick_RayOriginInsideEntitySphere_StillReturnsServerGuid()
{
// Player ~3m from a door -> camera near-plane sits INSIDE the door's
// 5m bounding sphere. Naive t_near < 0 guard would skip; correct
// behavior is to fall through to t_far (the sphere exit point).
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -3));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Equal(0xABCDu, result);
}
}

View file

@ -21,7 +21,7 @@ public sealed class GameplaySettingsTests
Assert.True(d.ToggleRun);
Assert.False(d.AdvancedCombatUI);
Assert.True(d.ShowTooltips);
Assert.False(d.VividTargetingIndicator);
Assert.True(d.VividTargetingIndicator); // retail default mask includes 0x00008000
Assert.False(d.SideBySideVitals);
Assert.True(d.CoordinatesOnRadar); // retail default mask includes 0x00400000
Assert.True(d.SpellDuration);