feat(ui): port retail radar and compass

This commit is contained in:
Erik 2026-07-10 16:14:37 +02:00
parent c4af181b92
commit 3cbe4b00a1
43 changed files with 2882 additions and 262 deletions

View file

@ -16,6 +16,17 @@ public class DatWidgetFactoryTests
Assert.IsType<UiMeter>(e);
}
[Fact]
public void RetailRadarClass_MakesUiRadar()
{
var e = DatWidgetFactory.Create(
new ElementInfo { Type = UiRadar.RetailClassId, Width = 120, Height = 140 },
NoTex,
null);
Assert.IsType<UiRadar>(e);
}
// ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
[Fact]

View file

@ -57,6 +57,14 @@ public static class FixtureLoader
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
=> LoadInfos("chat_21000006.json");
/// <summary>Builds the committed retail radar LayoutDesc 0x21000074 fixture.</summary>
public static ImportedLayout LoadRadar()
=> LayoutImporter.Build(LoadRadarInfos(), _ => (0u, 0, 0), null);
/// <summary>Returns the resolved ElementInfo tree for retail radar LayoutDesc 0x21000074.</summary>
public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos()
=> LoadInfos("radar_21000074.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -0,0 +1,159 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Ui;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RadarControllerTests
{
[Fact]
public void Bind_RealFixture_ReusesImportedCoordinateTextAndDatBackground()
{
var layout = FixtureLoader.LoadRadar();
_ = RadarController.Bind(
layout,
() => new UiRadarSnapshot(0f, Array.Empty<UiRadarBlip>(), "42.1N,33.6E"));
var coordinates = Assert.IsType<UiText>(
layout.FindElement(RadarController.CoordinateContainerId));
Assert.Equal(0x06004CC0u, coordinates.BackgroundSprite);
Assert.Empty(coordinates.Children);
Assert.True(coordinates.ClickThrough);
Assert.False(coordinates.AcceptsFocus);
Assert.Equal("42.1N,33.6E", Assert.Single(coordinates.LinesProvider()).Text);
}
[Fact]
public void Bind_UsesImportedChildrenForCompassCoordinatesAndLockChrome()
{
var layout = BuildRadarLayout();
var state = new UiRadarSnapshot(
0f,
Array.Empty<UiRadarBlip>(),
"42.1N,33.6E",
UiLocked: false);
bool? requestedLock = null;
_ = RadarController.Bind(
layout,
() => state,
setUiLocked: value => requestedLock = value);
var radar = Assert.IsType<UiRadar>(layout.Root);
Assert.Equal(new Vector2(60f, 60f), radar.Center);
Assert.True(radar.Draggable);
var coordinateContainer = Assert.IsType<UiDatElement>(
layout.FindElement(RadarController.CoordinateContainerId));
Assert.True(coordinateContainer.Visible);
var coordinateText = Assert.IsType<UiText>(Assert.Single(coordinateContainer.Children));
Assert.Equal("42.1N,33.6E", Assert.Single(coordinateText.LinesProvider()).Text);
var north = layout.FindElement(RadarController.NorthTokenId)!;
Assert.Equal(55f, north.Left);
Assert.Equal(1f, north.Top);
var lockButton = Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
Assert.Equal("UnlockedUI", lockButton.ActiveState);
var click = new UiEvent(lockButton.EventId, lockButton, UiEventType.Click);
Assert.True(lockButton.OnEvent(in click));
Assert.True(requestedLock);
state = state with
{
PlayerHeadingDegrees = 90f,
CoordinatesText = null,
UiLocked = true,
};
radar.Refresh();
Assert.False(radar.Draggable);
Assert.False(coordinateContainer.Visible);
Assert.False(coordinateText.Visible);
Assert.Equal("LockedUI", lockButton.ActiveState);
var drag = layout.FindElement(RadarController.DragButtonId)!;
Assert.False(drag.Visible);
Assert.False(drag.ClickThrough);
float northMagnitude = Vector2.Distance(new Vector2(60f, 5.5f), radar.Center);
var expectedNorth = RetailRadar.GetCompassTokenTopLeft(
90f,
RadarCompassPoint.North,
radar.Center,
northMagnitude,
new Vector2(north.Width, north.Height));
Assert.Equal(expectedNorth.X, north.Left);
Assert.Equal(expectedNorth.Y, north.Top);
}
[Fact]
public void Bind_LeavesStaticDatChildrenInRetainedTree()
{
var layout = BuildRadarLayout();
_ = RadarController.Bind(layout, () => UiRadarSnapshot.Empty);
Assert.Equal(8, layout.Root.Children.Count);
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.RadarDiscId));
Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.DragButtonId));
Assert.False(layout.FindElement(RadarController.CoordinateContainerId)!.Visible);
}
private static ImportedLayout BuildRadarLayout()
{
var root = new ElementInfo
{
Id = RadarController.RootId,
Type = UiRadar.RetailClassId,
Width = 120,
Height = 140,
};
root.Children.Add(Sprite(RadarController.CoordinateContainerId, 0, 120, 120, 18, 0x06004CC0u));
root.Children.Add(Sprite(RadarController.RadarDiscId, 0, 0, 120, 120, 0x06004CC1u));
root.Children.Add(Sprite(RadarController.NorthTokenId, 55, 1, 10, 9, 0x060011FBu));
root.Children.Add(Sprite(RadarController.EastTokenId, 110, 55, 10, 9, 0x06001938u));
root.Children.Add(Sprite(RadarController.SouthTokenId, 55, 110, 10, 9, 0x0600193Au));
root.Children.Add(Sprite(RadarController.WestTokenId, 0, 55, 10, 9, 0x0600193Cu));
var lockButton = new ElementInfo
{
Id = RadarController.LockButtonId,
Type = 1,
X = 6,
Y = 6,
Width = 27,
Height = 27,
};
lockButton.StateMedia["LockedUI"] = (0x060074B7u, 3);
lockButton.StateMedia["UnlockedUI"] = (0x060074B8u, 3);
root.Children.Add(lockButton);
root.Children.Add(Sprite(RadarController.DragButtonId, 87, 6, 27, 27, 0x060074C9u, type: 2));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
private static ElementInfo Sprite(
uint id,
float x,
float y,
float width,
float height,
uint sprite,
uint type = 3)
{
var info = new ElementInfo
{
Id = id,
Type = type,
X = x,
Y = y,
Width = width,
Height = height,
};
info.StateMedia[""] = (sprite, 1);
return info;
}
}

View file

@ -0,0 +1,77 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RadarLayoutConformanceTests
{
[Fact]
public void RadarFixture_HasRetailRootAndAllStaticAssets()
{
var root = FixtureLoader.LoadRadarInfos();
Assert.Equal(RadarController.RootId, root.Id);
Assert.Equal(UiRadar.RetailClassId, root.Type);
Assert.Equal(120f, root.Width);
Assert.Equal(140f, root.Height);
Assert.Equal(8, root.Children.Count);
AssertElement(root, RadarController.CoordinateContainerId, 0, 120, 120, 18, 0x06004CC0u);
AssertElement(root, RadarController.RadarDiscId, 0, 0, 120, 120, 0x06004CC1u);
AssertElement(root, RadarController.NorthTokenId, 55, 1, 10, 9, 0x060011FBu);
AssertElement(root, RadarController.EastTokenId, 110, 55, 10, 9, 0x06001938u);
AssertElement(root, RadarController.SouthTokenId, 55, 110, 10, 9, 0x0600193Au);
AssertElement(root, RadarController.WestTokenId, 0, 55, 10, 9, 0x0600193Cu);
AssertElement(root, RadarController.DragButtonId, 87, 6, 27, 27, 0x060074C9u);
var lockButton = Find(root, RadarController.LockButtonId)!;
Assert.Equal(1u, lockButton.Type);
Assert.Equal(0x060074B7u, lockButton.StateMedia["LockedUI"].File);
Assert.Equal(0x060074B8u, lockButton.StateMedia["UnlockedUI"].File);
}
[Fact]
public void RadarFixture_BuildsDedicatedWidgetAndPreservesStaticChildren()
{
var layout = FixtureLoader.LoadRadar();
Assert.IsType<UiRadar>(layout.Root);
Assert.Equal(8, layout.Root.Children.Count);
var coordinates = Assert.IsType<UiText>(
layout.FindElement(RadarController.CoordinateContainerId));
Assert.Equal(0x06004CC0u, coordinates.BackgroundSprite);
Assert.IsType<UiDatElement>(layout.FindElement(RadarController.RadarDiscId));
Assert.IsType<UiButton>(layout.FindElement(RadarController.LockButtonId));
}
private static void AssertElement(
ElementInfo root,
uint id,
float x,
float y,
float width,
float height,
uint sprite)
{
var element = Find(root, id)!;
Assert.Equal(x, element.X);
Assert.Equal(y, element.Y);
Assert.Equal(width, element.Width);
Assert.Equal(height, element.Height);
Assert.Equal(sprite, element.StateMedia[""].File);
}
private static ElementInfo? Find(ElementInfo node, uint id)
{
if (node.Id == id)
return node;
foreach (var child in node.Children)
{
var found = Find(child, id);
if (found is not null)
return found;
}
return null;
}
}

View file

@ -0,0 +1,36 @@
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>One-off generator for the committed retail radar LayoutDesc fixture.</summary>
public sealed class RadarLayoutFixtureGenerator
{
[Fact(Skip = "manual: regenerates the committed radar fixture; needs the real dats (ACDREAM_DAT_DIR)")]
public void GenerateRadarFixture()
{
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var info = LayoutImporter.ImportInfos(dats, RadarController.LayoutId);
Assert.NotNull(info);
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(FixturePath(), json);
}
private static string FixturePath([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "radar_21000074.json");
}

View file

@ -0,0 +1,146 @@
using System.Numerics;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RadarSnapshotProviderTests
{
[Fact]
public void BuildSnapshot_JoinsWorldAndWeenieTables_UsingCoreRetailMath()
{
const uint player = 1u;
const uint monster = 2u;
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<uint, WorldEntity>
{
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
[monster] = Entity(monster, new Vector3(0f, 15f, 6f), Quaternion.Identity),
};
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
{
[player] = Spawn(player) with { ObjectDescriptionFlags = 0x00000008u }, // BF_PLAYER
[monster] = Spawn(monster) with { ObjectDescriptionFlags = 0x00000010u }, // BF_ATTACKABLE
};
uint? selected = monster;
var provider = new RadarSnapshotProvider(
objects, entities, spawns,
playerGuid: () => player,
playerYawRadians: () => MathF.PI / 2f, // retail heading 0 degrees
playerCellId: () => 0xA9B40001u,
selectedGuid: () => selected,
coordinatesOnRadar: () => true,
uiLocked: () => false);
var snapshot = provider.BuildSnapshot();
Assert.Equal(0f, snapshot.PlayerHeadingDegrees, 3);
Assert.Equal(RadarCoordinates.TryFromCell(0xA9B40001u, out var coords)
? coords.CombinedText : null, snapshot.CoordinatesText);
var blip = Assert.Single(snapshot.Blips);
Assert.Equal(monster, blip.ObjectId);
Assert.Equal("Drudge", blip.Name);
Assert.Equal(60f, blip.PixelX);
Assert.Equal(50f, blip.PixelY); // 15 m * 50 px / 75 m
Assert.Equal(RadarBlipShape.Plus, blip.Shape);
Assert.True(blip.Selected);
Assert.Equal(RadarBlipColors.Gold.Red * 0.65f, blip.Color.X, 5);
Assert.Equal(RadarBlipColors.Gold.Green * 0.65f, blip.Color.Y, 5);
}
[Fact]
public void BuildSnapshot_RejectsShowNever_AndHidesIndoorCoordinates()
{
const uint player = 10u;
const uint hidden = 11u;
var objects = new ClientObjectTable();
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
objects.Ingest(Weenie(hidden, "Hidden", ItemType.Portal) with
{
RadarBehavior = (byte)RadarBehavior.ShowNever,
});
var entities = new Dictionary<uint, WorldEntity>
{
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
[hidden] = Entity(hidden, new Vector3(2f, 2f, 0f), Quaternion.Identity),
};
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
{
[player] = Spawn(player),
[hidden] = Spawn(hidden),
};
var provider = new RadarSnapshotProvider(
objects, entities, spawns,
playerGuid: () => player,
playerYawRadians: () => 0f,
playerCellId: () => 0xA9B40100u, // EnvCell: no landscape coordinate
selectedGuid: () => null,
coordinatesOnRadar: () => true,
uiLocked: () => true);
var snapshot = provider.BuildSnapshot();
Assert.Empty(snapshot.Blips);
Assert.Null(snapshot.CoordinatesText);
Assert.True(snapshot.UiLocked);
}
private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new()
{
Id = guid,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0u,
Position = position,
Rotation = rotation,
MeshRefs = Array.Empty<MeshRef>(),
};
private static WorldSession.EntitySpawn Spawn(uint guid) => new(
Guid: guid,
Position: null,
SetupTableId: null,
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: null,
ItemType: null,
MotionState: null,
MotionTableId: null);
private static WeenieData Weenie(uint guid, string name, ItemType type) => new(
Guid: guid,
Name: name,
Type: type,
WeenieClassId: 1u,
IconId: 0u,
IconOverlayId: 0u,
IconUnderlayId: 0u,
Effects: 0u,
Value: null,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: null,
WielderId: null,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null);
}

View file

@ -0,0 +1,250 @@
{
"Id": 268437203,
"Type": 268435472,
"X": 0,
"Y": 0,
"Width": 120,
"Height": 140,
"Left": 1,
"Top": 1,
"Right": 1,
"Bottom": 1,
"ReadOrder": 0,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {},
"StateCursors": {},
"DefaultStateName": "",
"Children": [
{
"Id": 268435518,
"Type": 12,
"X": 0,
"Y": 120,
"Width": 120,
"Height": 18,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 1,
"ZLevel": 0,
"FontDid": 1073741825,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100682944,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435519,
"Type": 3,
"X": 0,
"Y": 0,
"Width": 120,
"Height": 120,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 2,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100682945,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435520,
"Type": 3,
"X": 55,
"Y": 1,
"Width": 10,
"Height": 9,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 5,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100667899,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435521,
"Type": 3,
"X": 110,
"Y": 55,
"Width": 10,
"Height": 9,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 6,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100669752,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268435522,
"Type": 3,
"X": 55,
"Y": 110,
"Width": 10,
"Height": 9,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 7,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100669754,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268437155,
"Type": 2,
"X": 87,
"Y": 6,
"Width": 27,
"Height": 27,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 4,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100693193,
"Item2": 3
}
},
"StateCursors": {
"": {
"File": 100688153,
"HotspotX": 16,
"HotspotY": 16,
"IsValid": true
}
},
"DefaultStateName": "",
"Children": []
},
{
"Id": 268437017,
"Type": 1,
"X": 6,
"Y": 6,
"Width": 27,
"Height": 27,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 3,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"LockedUI": {
"Item1": 100693175,
"Item2": 3
},
"UnlockedUI": {
"Item1": 100693176,
"Item2": 3
}
},
"StateCursors": {},
"DefaultStateName": "Normal",
"Children": []
},
{
"Id": 268435523,
"Type": 3,
"X": 0,
"Y": 55,
"Width": 10,
"Height": 9,
"Left": 0,
"Top": 0,
"Right": 0,
"Bottom": 0,
"ReadOrder": 8,
"ZLevel": 0,
"FontDid": 0,
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"StateMedia": {
"": {
"Item1": 100669756,
"Item2": 1
}
},
"StateCursors": {},
"DefaultStateName": "",
"Children": []
}
]
}

View file

@ -0,0 +1,91 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Ui;
namespace AcDream.App.Tests.UI;
public sealed class UiRadarTests
{
[Fact]
public void HoverAndClick_SelectNearestProjectedBlip()
{
uint? selected = null;
uint? hovered = null;
var radar = new UiRadar
{
Width = 120,
Height = 140,
SelectObject = guid => selected = guid,
HoveredObjectChanged = guid => hovered = guid,
};
radar.ApplySnapshot(new UiRadarSnapshot(
0f,
[
new UiRadarBlip(0x10u, "Far", 31f, 30f, Vector4.One, RadarBlipShape.Plus),
new UiRadarBlip(0x20u, "Near", 28f, 30f, Vector4.One, RadarBlipShape.Circle),
],
"1.0N,1.0E"));
var root = new UiRoot { Width = 200, Height = 200 };
root.AddChild(radar);
root.OnMouseMove(27, 30);
Assert.Equal(0x20u, radar.HoveredObjectId);
Assert.Equal(0x20u, hovered);
Assert.Equal("Near", radar.GetTooltipText());
root.OnMouseDown(UiMouseButton.Left, 27, 30);
root.OnMouseUp(UiMouseButton.Left, 27, 30);
Assert.Equal(0x20u, selected);
root.OnMouseMove(100, 100);
Assert.Null(radar.HoveredObjectId);
Assert.Null(hovered);
Assert.Null(radar.GetTooltipText());
}
[Fact]
public void BlankBlips_DisablesHoverButKeepsSnapshot()
{
var radar = new UiRadar { Width = 120, Height = 140 };
radar.ApplySnapshot(new UiRadarSnapshot(
0f,
[new UiRadarBlip(0x10u, "Hidden", 30f, 30f, Vector4.One, RadarBlipShape.Plus)],
null,
BlankBlips: true));
var root = new UiRoot { Width = 200, Height = 200 };
root.AddChild(radar);
root.OnMouseMove(30, 30);
Assert.Single(radar.Snapshot.Blips);
Assert.Null(radar.HoveredObjectId);
}
[Fact]
public void Tick_PollsProviderAtRetailTwentyFiveMillisecondCadence()
{
int calls = 0;
var radar = new UiRadar
{
Width = 120,
Height = 140,
SnapshotProvider = () =>
{
calls++;
return UiRadarSnapshot.Empty;
},
};
var root = new UiRoot { Width = 200, Height = 200 };
root.AddChild(radar);
root.Tick(0.024, 24);
Assert.Equal(0, calls);
root.Tick(0.0011, 25);
Assert.Equal(1, calls);
root.Tick(0.100, 125);
Assert.Equal(2, calls); // retail schedules one fresh update; it does not replay four stale ticks
}
}

View file

@ -187,6 +187,59 @@ public class UiRootInputTests
Assert.Equal(80f, panel.Top);
}
[Fact]
public void WindowDrag_ConstrainedPanel_StaysFullyInsideParent()
{
var root = new UiRoot { Width = 200, Height = 150 };
var panel = new UiPanel
{
Left = 10,
Top = 10,
Width = 120,
Height = 100,
Draggable = true,
ConstrainDragToParent = true,
};
root.AddChild(panel);
root.RegisterWindow("radar", panel);
(string name, UiElement window)? moved = null;
root.WindowMoved += (name, window) => moved = (name, window);
root.OnMouseDown(UiMouseButton.Left, 20, 20);
root.OnMouseMove(500, 500);
Assert.Equal(80f, panel.Left);
Assert.Equal(50f, panel.Top);
root.OnMouseMove(-500, -500);
Assert.Equal(0f, panel.Left);
Assert.Equal(0f, panel.Top);
root.OnMouseUp(UiMouseButton.Left, -500, -500);
Assert.Equal("radar", moved?.name);
Assert.Same(panel, moved?.window);
}
[Fact]
public void UiLocked_BlocksWindowMoveWithoutDisablingWindow()
{
var root = new UiRoot { Width = 200, Height = 150, UiLocked = true };
var panel = new UiPanel
{
Left = 10,
Top = 10,
Width = 100,
Height = 60,
Draggable = true,
};
root.AddChild(panel);
root.OnMouseDown(UiMouseButton.Left, 20, 20);
root.OnMouseMove(100, 100);
Assert.Equal(10f, panel.Left);
Assert.Equal(10f, panel.Top);
Assert.True(panel.Enabled);
}
[Fact]
public void NonDraggablePanel_DoesNotMoveOnDrag()
{

View file

@ -172,6 +172,70 @@ public sealed class CreateObjectTests
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
}
// -----------------------------------------------------------------------
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
// Absence is distinct from an explicitly transmitted zero because zero is
// the enum's undefined/default value, not proof the field was omitted.
// -----------------------------------------------------------------------
[Fact]
public void TryParse_NoRadarFlags_LeavesRadarFieldsNull()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x5000000Au,
name: "Unspecified Radar Object",
itemType: (uint)ItemType.Misc);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Null(parsed.Value.RadarBlipColor);
Assert.Null(parsed.Value.RadarBehavior);
}
[Fact]
public void TryParse_RadarFlags_CapturesBothBytesAndContinuesTailWalk()
{
const uint radarBlipColorFlag = 0x00100000u;
const uint radarBehaviorFlag = 0x00800000u;
const uint workmanshipFlag = 0x01000000u;
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x5000000Bu,
name: "Radar Portal",
itemType: (uint)ItemType.Portal,
weenieFlags: radarBlipColorFlag | radarBehaviorFlag | workmanshipFlag,
radarBlipColor: 4,
radarBehavior: 4,
workmanship: 6.25f);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal((byte)4, parsed.Value.RadarBlipColor);
Assert.Equal((byte)4, parsed.Value.RadarBehavior);
Assert.Equal(6.25f, parsed.Value.Workmanship);
}
[Fact]
public void TryParse_RadarFlagsWithZero_PreservesExplicitDefaultValues()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x5000000Cu,
name: "Explicit Radar Defaults",
itemType: (uint)ItemType.Misc,
weenieFlags: 0x00100000u | 0x00800000u,
radarBlipColor: 0,
radarBehavior: 0);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.True(parsed.Value.RadarBlipColor.HasValue);
Assert.Equal((byte)0, parsed.Value.RadarBlipColor.Value);
Assert.True(parsed.Value.RadarBehavior.HasValue);
Assert.Equal((byte)0, parsed.Value.RadarBehavior.Value);
}
// -----------------------------------------------------------------------
// D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the
// action bar / equipment UI can read icon dat ids from spawn messages.
@ -500,6 +564,8 @@ public sealed class CreateObjectTests
uint? currentWieldedLocation = null,
uint? priority = null,
float? workmanship = null,
byte? radarBlipColor = null,
byte? radarBehavior = null,
ushort movementSeq = 0)
{
var bytes = new List<byte>();
@ -562,8 +628,8 @@ public sealed class CreateObjectTests
if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations u32
if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation u32
if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority u32
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(0); // RadarBlipColor u8
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(0); // RadarBehavior u8
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(radarBlipColor ?? 0); // RadarBlipColor u8
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(radarBehavior ?? 0); // RadarBehavior u8
if ((weenieFlags & 0x08000000u) != 0) WriteU16(bytes, 0); // PScript u16
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
{

View file

@ -59,6 +59,8 @@ public sealed class ObjectTableWiringTests
Workmanship = 4.5f,
Useability = 0x000A0008u,
TargetType = (uint)ItemType.Creature,
RadarBlipColor = 4,
RadarBehavior = 3,
};
var d = ObjectTableWiring.ToWeenieData(spawn);
@ -100,6 +102,8 @@ public sealed class ObjectTableWiringTests
Assert.Equal(4.5f, d.Workmanship);
Assert.Equal(0x000A0008u, d.Useability);
Assert.Equal((uint)ItemType.Creature, d.TargetType);
Assert.Equal((byte)4, d.RadarBlipColor);
Assert.Equal((byte)3, d.RadarBehavior);
}
// -------------------------------------------------------------------------

View file

@ -0,0 +1,44 @@
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
public sealed class WorldSessionRadarTests
{
[Fact]
public void ToEntitySpawn_PreservesRadarBytes()
{
var parsed = MinimalParsed() with
{
RadarBlipColor = 7,
RadarBehavior = 3,
};
var spawn = WorldSession.ToEntitySpawn(parsed);
Assert.Equal((byte)7, spawn.RadarBlipColor);
Assert.Equal((byte)3, spawn.RadarBehavior);
}
[Fact]
public void ToEntitySpawn_PreservesMissingRadarFields()
{
var spawn = WorldSession.ToEntitySpawn(MinimalParsed());
Assert.Null(spawn.RadarBlipColor);
Assert.Null(spawn.RadarBehavior);
}
private static CreateObject.Parsed MinimalParsed() => new(
Guid: 0x50000001u,
Position: null,
SetupTableId: null,
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: "Radar Test",
ItemType: null,
MotionState: null,
MotionTableId: null);
}

View file

@ -277,6 +277,28 @@ public sealed class ClientObjectTableTests
Assert.Equal((uint)ItemType.Creature, item.TargetType);
}
[Fact]
public void Ingest_RadarMetadata_PatchesOnlyPresentWireFields()
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x500000B8u) with
{
RadarBlipColor = 4,
RadarBehavior = 3,
});
table.Ingest(FullWeenie(0x500000B8u) with
{
RadarBlipColor = null,
RadarBehavior = 4,
});
var item = table.Get(0x500000B8u);
Assert.NotNull(item);
Assert.Equal((byte)4, item!.RadarBlipColor);
Assert.Equal((byte)4, item.RadarBehavior);
}
[Fact]
public void RecordMembership_CreatesEntry_AndSetsEquip()
{

View file

@ -1,83 +1,132 @@
using AcDream.Core.Items;
using AcDream.Core.Ui;
using Xunit;
namespace AcDream.Core.Tests.Ui;
public sealed class RadarBlipColorsTests
{
// PWD bit constants per docs/research/named-retail/acclient.h:6431-6463
private const uint BF_PLAYER = 0x8u;
private const uint BF_PLAYER_KILLER = 0x20u;
private const uint BF_VENDOR = 0x200u;
private const uint BF_PORTAL = 0x40000u;
private const uint BF_PKLITE_PKSTATUS = 0x2000000u;
// PublicWeenieDesc::BitfieldIndex, acclient.h:6431.
private const uint BfPlayer = 0x00000008u;
private const uint BfAttackable = 0x00000010u;
private const uint BfPlayerKiller = 0x00000020u;
private const uint BfHiddenAdmin = 0x00000040u;
private const uint BfVendor = 0x00000200u;
private const uint BfPortal = 0x00040000u;
private const uint BfAdmin = 0x00100000u;
private const uint BfFreePk = 0x00200000u;
private const uint BfPkLite = 0x02000000u;
[Fact]
public void Item_NoFlags_ReturnsItemColor()
public void Palette_UsesExactRetailFloatConstants()
{
// SpellComponents is itemType=0x1000 (e.g. a Taper) — not a creature.
var result = RadarBlipColors.For(itemType: (uint)ItemType.SpellComponents, pwdBitfield: 0);
Assert.Equal(RadarBlipColors.Item, result);
Assert.Equal(new RadarBlipColors.Rgba(0.25f, 0.660000026f, 1f, 1f), RadarBlipColors.Blue);
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.670000017f, 0f, 1f), RadarBlipColors.Gold);
Assert.Equal(new RadarBlipColors.Rgba(1f, 1f, 0.5f, 1f), RadarBlipColors.Yellow);
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.25f, 0.389999986f, 1f), RadarBlipColors.Red);
Assert.Equal(new RadarBlipColors.Rgba(0.75f, 0.389999986f, 1f, 1f), RadarBlipColors.Purple);
Assert.Equal(new RadarBlipColors.Rgba(1f, 0.660000026f, 0.75f, 1f), RadarBlipColors.Pink);
Assert.Equal(new RadarBlipColors.Rgba(0f, 0.5f, 0.25f, 1f), RadarBlipColors.Green);
}
[Fact]
public void Misc_NoFlags_ReturnsItemColor()
public void Override_MapsValuesOneThroughTenInRetailOrder()
{
var result = RadarBlipColors.For((uint)ItemType.Misc, pwdBitfield: 0);
Assert.Equal(RadarBlipColors.Item, result);
RadarBlipColors.Rgba[] expected =
[
RadarBlipColors.Blue,
RadarBlipColors.Gold,
RadarBlipColors.White,
RadarBlipColors.Purple,
RadarBlipColors.Red,
RadarBlipColors.Pink,
RadarBlipColors.Green,
RadarBlipColors.Yellow,
RadarBlipColors.Cyan,
RadarBlipColors.BrightGreen,
];
for (byte value = 1; value <= expected.Length; value++)
Assert.Equal(expected[value - 1], RadarBlipColors.ForOverride(value));
Assert.Equal(RadarBlipColors.Default, RadarBlipColors.ForOverride(11));
}
[Fact]
public void Creature_NotPlayer_ReturnsCreatureColor()
public void WireOverride_WinsOverObjectClassification()
{
// NPC: itemType has Creature bit, no Player flag.
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: 0);
Assert.Equal(RadarBlipColors.Creature, result);
var traits = new RadarObjectTraits(
BlipColorOverride: 1,
IsPortal: true,
IsVendor: true,
IsPlayer: true,
IsPlayerKiller: true);
Assert.Equal(RadarBlipColors.Blue, RadarBlipColors.For(traits));
}
[Fact]
public void FriendlyPlayer_ReturnsDefaultColor()
public void PortalAndVendor_UsePurpleAndYellow()
{
// Friendly player: Creature itemType + Player flag, no PK bits.
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: BF_PLAYER);
Assert.Equal(RadarBlipColors.Default, result);
Assert.Equal(
RadarBlipColors.Purple,
RadarBlipColors.For((uint)ItemType.Creature, BfPortal | BfVendor));
Assert.Equal(
RadarBlipColors.Yellow,
RadarBlipColors.For((uint)ItemType.Creature, BfVendor));
}
[Fact]
public void PK_Player_ReturnsPlayerKillerColor()
public void Creature_MustBeAttackableAndNotAPlayer()
{
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_PLAYER | BF_PLAYER_KILLER);
Assert.Equal(RadarBlipColors.PlayerKiller, result);
Assert.Equal(
RadarBlipColors.Gold,
RadarBlipColors.For((uint)ItemType.Creature, BfAttackable));
Assert.Equal(
RadarBlipColors.White,
RadarBlipColors.For((uint)ItemType.Creature, 0));
Assert.Equal(
RadarBlipColors.White,
RadarBlipColors.For((uint)ItemType.Creature, BfAttackable | BfPlayer));
}
[Fact]
public void PKLite_Player_ReturnsPKLiteColor()
public void Player_StatusDispatch_MatchesRetailPriority()
{
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_PLAYER | BF_PKLITE_PKSTATUS);
Assert.Equal(RadarBlipColors.PKLite, result);
uint creature = (uint)ItemType.Creature;
Assert.Equal(RadarBlipColors.Cyan, RadarBlipColors.For(creature, BfPlayer | BfAdmin));
Assert.Equal(RadarBlipColors.Red, RadarBlipColors.For(creature, BfPlayer | BfPlayerKiller));
Assert.Equal(RadarBlipColors.Pink, RadarBlipColors.For(creature, BfPlayer | BfPkLite));
Assert.Equal(RadarBlipColors.Gold, RadarBlipColors.For(creature, BfPlayer | BfFreePk));
Assert.Equal(
RadarBlipColors.Red,
RadarBlipColors.For(creature, BfPlayer | BfAdmin | BfHiddenAdmin | BfPlayerKiller));
}
[Fact]
public void Vendor_BeatsCreatureFlag()
public void Fellowship_OverridesPlayerStatusColor()
{
// A vendor NPC: Creature itemType + Vendor flag. Vendor wins per
// retail's dispatch order (vendor check happens before creature
// check at 0x004d7946-004d7973).
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_VENDOR);
Assert.Equal(RadarBlipColors.Vendor, result);
var traits = new RadarObjectTraits(IsPlayer: true, IsPlayerKiller: true);
var relationship = new RadarRelationshipTraits(
IsFellowshipMember: true,
IsFellowshipLeader: true);
Assert.Equal(RadarBlipColors.BrightGreen, RadarBlipColors.For(traits, relationship));
}
[Fact]
public void Portal_TopPriority()
public void InvalidDescription_ReturnsDefault()
{
// Portal flag wins over everything else (retail dispatch order
// checks BF_PORTAL first).
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_PORTAL | BF_PLAYER | BF_PLAYER_KILLER);
Assert.Equal(RadarBlipColors.Portal, result);
var traits = new RadarObjectTraits(IsValid: false, BlipColorOverride: 1, IsPortal: true);
Assert.Equal(RadarBlipColors.Default, RadarBlipColors.For(traits));
}
[Fact]
public void Rgba8Projection_RemainsAvailableToExistingUiConsumers()
{
Assert.Equal((byte)64, RadarBlipColors.Blue.R);
Assert.Equal((byte)168, RadarBlipColors.Blue.G);
Assert.Equal(0xFFFFA840u, RadarBlipColors.Blue.ToAbgr32());
}
}

View file

@ -0,0 +1,209 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Ui;
namespace AcDream.Core.Tests.Ui;
public sealed class RetailRadarTests
{
[Theory]
[InlineData(RadarBehavior.Undef, true, false)]
[InlineData(RadarBehavior.ShowNever, true, false)]
[InlineData(RadarBehavior.ShowMovement, true, true)]
[InlineData(RadarBehavior.ShowAttacking, true, true)]
[InlineData(RadarBehavior.ShowAlways, true, true)]
[InlineData(RadarBehavior.ShowAlways, false, false)]
public void Showability_AcceptsRetailEnumValuesWithoutLiveStateChecks(
RadarBehavior behavior,
bool hasPhysics,
bool expected)
=> Assert.Equal(expected, RetailRadar.IsShowable(behavior, hasPhysics));
[Fact]
public void ShapeClassification_UsesFellowshipAllegianceAndMutualPkPriority()
{
var pk = new RadarObjectTraits(IsPlayerKiller: true);
var pkLite = new RadarObjectTraits(IsPkLite: true);
Assert.Equal(RadarBlipShape.Default, RetailRadar.GetBlipShape(pk));
Assert.Equal(
RadarBlipShape.Threat,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(PlayerIsPlayerKiller: true)));
Assert.Equal(
RadarBlipShape.Threat,
RetailRadar.GetBlipShape(pkLite, new RadarRelationshipTraits(PlayerIsPkLite: true)));
Assert.Equal(
RadarBlipShape.AllegianceMember,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
IsAllegianceMember: true,
PlayerIsPlayerKiller: true)));
Assert.Equal(
RadarBlipShape.Fellowship,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
IsFellowshipMember: true,
IsAllegianceMember: true,
PlayerIsPlayerKiller: true)));
Assert.Equal(
RadarBlipShape.FellowshipLeader,
RetailRadar.GetBlipShape(pk, new RadarRelationshipTraits(
IsFellowshipMember: true,
IsFellowshipLeader: true)));
Assert.Equal(
RadarBlipShape.Undef,
RetailRadar.GetBlipShape(new RadarObjectTraits(IsValid: false)));
}
[Fact]
public void BlipPixelShapes_MatchRetailDrawRoutines()
{
AssertOffsets(RadarBlipShape.Circle, [(0, 0)]);
AssertOffsets(RadarBlipShape.Plus, [(0, 0), (0, -1), (0, 1), (-1, 0), (1, 0)]);
AssertOffsets(RadarBlipShape.X, [(0, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
AssertOffsets(
RadarBlipShape.Box,
[(0, -1), (0, 1), (-1, 0), (1, 0), (1, 1), (-1, -1), (-1, 1), (1, -1)]);
AssertOffsets(RadarBlipShape.Triangle, [(0, 0), (-1, 1), (0, 1), (1, 1)]);
AssertOffsets(RadarBlipShape.InvertedTriangle, [(0, 0), (-1, -1), (0, -1), (1, -1)]);
AssertOffsets(
RadarBlipShape.XBox,
[
(0, -1), (0, 1), (-1, 0), (1, 0),
(1, 1), (-1, -1), (-1, 1), (1, -1),
(-2, -2), (2, -2), (-2, 2), (2, 2),
]);
Assert.Empty(RetailRadar.GetBlipPixels(RadarBlipShape.Undef).ToArray());
}
[Fact]
public void CenterMarkerAndSelectionOutline_MatchRetailPixelCountsAndExtents()
{
var player = RetailRadar.PlayerMarkerPixels.ToArray();
Assert.Equal(
new[]
{
new RadarPixelOffset(0, 0),
new RadarPixelOffset(0, -1),
new RadarPixelOffset(0, 1),
new RadarPixelOffset(-1, 0),
new RadarPixelOffset(1, 0),
new RadarPixelOffset(-2, 0),
new RadarPixelOffset(2, 0),
new RadarPixelOffset(0, -2),
new RadarPixelOffset(0, 2),
},
player);
var selected = RetailRadar.SelectionPixels.ToArray();
Assert.Equal(20, selected.Length);
Assert.All(selected, p => Assert.True(Math.Abs(p.X) == 3 || Math.Abs(p.Y) == 3));
for (int coordinate = -2; coordinate <= 2; coordinate++)
{
Assert.Contains(new RadarPixelOffset(coordinate, -3), selected);
Assert.Contains(new RadarPixelOffset(coordinate, 3), selected);
Assert.Contains(new RadarPixelOffset(-3, coordinate), selected);
Assert.Contains(new RadarPixelOffset(3, coordinate), selected);
}
}
[Fact]
public void Projection_UsesRetailRangeScaleAndScreenYAxis()
{
bool visible = RetailRadar.TryProject(
new Vector3(10f, 20f, 4.999f),
new Vector2(60f, 60f),
radarRadiusPixels: 55,
radarRangeMeters: RetailRadar.OutdoorRangeMeters,
out var projection);
Assert.True(visible);
Assert.Equal(new Vector2(67f, 45f), projection.Pixel);
Assert.Equal(1f, projection.RgbMultiplier);
}
[Fact]
public void Projection_ExcludesExactRangeMinusOneBoundary()
{
Assert.True(RetailRadar.TryProject(
new Vector3(73.999f, 0f, 0f),
Vector2.Zero,
55,
RetailRadar.OutdoorRangeMeters,
out _));
Assert.False(RetailRadar.TryProject(
new Vector3(74f, 0f, 0f),
Vector2.Zero,
55,
RetailRadar.OutdoorRangeMeters,
out _));
}
[Theory]
[InlineData(4.999f, 1f)]
[InlineData(-4.999f, 1f)]
[InlineData(5f, 0.65f)]
[InlineData(-5f, 0.65f)]
public void AltitudeDim_UsesStrictFiveMeterThreshold(float z, float expected)
=> Assert.Equal(expected, RetailRadar.GetAltitudeRgbMultiplier(z));
[Fact]
public void CompassTokens_OrbitUsingPhysicalHeading()
{
Vector2 center = new(60f, 60f);
Vector2 size = new(10f, 10f);
Assert.Equal(
new Vector2(55f, 0f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.North, center, 55f, size));
Assert.Equal(
// Retail's east offset is the float 1.57079637; after x87 cosine
// and C++ float-to-int truncation its center is one subpixel above 60.
new Vector2(110f, 54f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.East, center, 55f, size));
Assert.Equal(
new Vector2(55f, 110f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.South, center, 55f, size));
Assert.Equal(
new Vector2(0f, 55f),
RetailRadar.GetCompassTokenTopLeft(0f, RadarCompassPoint.West, center, 55f, size));
Assert.Equal(
new Vector2(0f, 55f),
RetailRadar.GetCompassTokenTopLeft(90f, RadarCompassPoint.North, center, 55f, size));
}
[Fact]
public void Coordinates_UseOutdoorLandcellAndRetailAxisOrder()
{
uint cellId = LandDefs.LcoordToGid(1358, 1440);
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
Assert.Equal(33.9, coordinates.X, precision: 10);
Assert.Equal(42.1, coordinates.Y, precision: 10);
Assert.Equal("33.9E", coordinates.XText);
Assert.Equal("42.1N", coordinates.YText);
Assert.Equal("42.1N,33.9E", coordinates.CombinedText);
}
[Fact]
public void Coordinates_FormatWestSouthAndZeroWithoutDirection()
{
uint cellId = LandDefs.LcoordToGid(900, 800);
Assert.True(RadarCoordinates.TryFromCell(cellId, out var coordinates));
Assert.Equal("11.9W", coordinates.XText);
Assert.Equal("21.9S", coordinates.YText);
cellId = LandDefs.LcoordToGid(1019, 1019);
Assert.True(RadarCoordinates.TryFromCell(cellId, out coordinates));
Assert.Equal("0.0", coordinates.XText);
Assert.Equal("0.0,0.0", coordinates.CombinedText);
}
[Fact]
public void Coordinates_AreUnavailableIndoors()
=> Assert.False(RadarCoordinates.TryFromCell(0xA9B40164u, out _));
private static void AssertOffsets(RadarBlipShape shape, (int X, int Y)[] expected)
=> Assert.Equal(
expected.Select(p => new RadarPixelOffset(p.X, p.Y)),
RetailRadar.GetBlipPixels(shape).ToArray());
}

View file

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

View file

@ -368,4 +368,20 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.False(store.LoadChat().HearTradeChat);
Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel);
}
[Fact]
public void WindowPositions_RoundTripPerCharacterAndPreserveSettings()
{
var store = new SettingsStore(_tempPath);
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f));
store.SaveWindowPosition("Bob", "radar", new UiWindowPosition(44f, 55f));
Assert.Equal(new UiWindowPosition(321.5f, 18f),
store.LoadWindowPosition("Alice", "radar"));
Assert.Equal(new UiWindowPosition(44f, 55f),
store.LoadWindowPosition("Bob", "radar"));
Assert.Null(store.LoadWindowPosition("Alice", "inventory"));
Assert.True(store.LoadGameplay().LockUI);
}
}