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": []
}
]
}