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

@ -760,6 +760,11 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
// Phase D.6 — retail gmRadarUI (LayoutDesc 0x21000074). Behavior lives in
// Core + the retained controller; GameWindow supplies live state only.
private AcDream.App.UI.Layout.RadarController? _radarController;
private AcDream.App.UI.Layout.RadarSnapshotProvider? _radarSnapshotProvider;
private AcDream.App.UI.UiRadar? _radarRoot;
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
@ -1738,6 +1743,11 @@ public sealed class GameWindow : IDisposable
try
{
settingsStore.SaveGameplay(gameplay);
// Runtime consumers (including retained gmRadarUI) read
// the live persisted snapshot, not SettingsVM's draft.
_persistedGameplay = gameplay;
if (_uiHost is not null)
_uiHost.Root.UiLocked = gameplay.LockUI;
Console.WriteLine(
"settings: gameplay saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
@ -1998,6 +2008,7 @@ public sealed class GameWindow : IDisposable
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
@ -2025,6 +2036,7 @@ public sealed class GameWindow : IDisposable
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
_uiHost.Root.WindowMoved += OnRetailWindowMoved;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
@ -2110,6 +2122,52 @@ public sealed class GameWindow : IDisposable
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
}
// Phase D.6 — retail gmRadarUI. The importer owns the exact static
// LayoutDesc 0x21000074 shell; RadarController owns retained behavior;
// RadarSnapshotProvider is the thin live-world adapter.
AcDream.App.UI.Layout.ImportedLayout? radarLayout;
lock (_datLock)
radarLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, AcDream.App.UI.Layout.RadarController.LayoutId,
ResolveChrome, vitalsDatFont, ResolveDatFont);
if (radarLayout is not null && radarLayout.Root is AcDream.App.UI.UiRadar radarRoot)
{
_radarRoot = radarRoot;
_radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
_entitiesByServerGuid,
_lastSpawnByGuid,
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selectedGuid,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI);
_radarController = AcDream.App.UI.Layout.RadarController.Bind(
radarLayout,
snapshotProvider: _radarSnapshotProvider.BuildSnapshot,
selectObject: guid => SelectedGuid = guid,
setUiLocked: SetRetailUiLocked,
datFont: vitalsDatFont);
radarRoot.Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f);
radarRoot.Top = 10f;
radarRoot.ClickThrough = false;
// User-positioned top-level window: Anchors.None prevents the
// per-frame anchor pass from undoing drag-handle movement.
radarRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
radarRoot.Resizable = false;
radarRoot.ConstrainDragToParent = true;
_uiHost.Root.AddChild(radarRoot);
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Radar, radarRoot);
ApplySavedRadarPosition();
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
}
else
{
Console.WriteLine("[D.6] radar: LayoutDesc 0x21000074 not found or root class mismatch.");
}
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
// the same importer path as vitals. ChatWindowController binds the transcript,
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
@ -2866,6 +2924,7 @@ public sealed class GameWindow : IDisposable
_liveSession.EnterWorld(user, characterIndex: 0);
_activeToonKey = chosen.Name;
ApplySavedRadarPosition();
if (_settingsStore is not null && _settingsVm is not null)
{
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
@ -11776,6 +11835,62 @@ public sealed class GameWindow : IDisposable
_toolbarController.SetCharacterOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Character));
}
private void SetRetailUiLocked(bool locked)
{
if (_persistedGameplay.LockUI == locked)
return;
_persistedGameplay = _persistedGameplay with { LockUI = locked };
if (_uiHost is not null)
_uiHost.Root.UiLocked = locked;
if (_settingsVm is not null)
_settingsVm.SetGameplay(_settingsVm.GameplayDraft with { LockUI = locked });
try
{
_settingsStore?.SaveGameplay(_persistedGameplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: radar lock save failed: {ex.Message}");
}
}
private void ApplySavedRadarPosition()
{
if (_radarRoot is null || _settingsStore is null || _window is null)
return;
var saved = _settingsStore.LoadWindowPosition(
_activeToonKey, AcDream.App.UI.WindowNames.Radar);
if (saved is not { } position)
return;
_radarRoot.Left = System.Math.Clamp(
position.X, 0f, System.Math.Max(0f, _window.Size.X - _radarRoot.Width));
_radarRoot.Top = System.Math.Clamp(
position.Y, 0f, System.Math.Max(0f, _window.Size.Y - _radarRoot.Height));
}
private void OnRetailWindowMoved(string name, AcDream.App.UI.UiElement window)
{
if (name != AcDream.App.UI.WindowNames.Radar || _settingsStore is null)
return;
try
{
_settingsStore.SaveWindowPosition(
_activeToonKey,
name,
new AcDream.UI.Abstractions.Panels.Settings.UiWindowPosition(
window.Left, window.Top));
}
catch (Exception ex)
{
Console.WriteLine($"settings: radar position save failed: {ex.Message}");
}
}
private void UpdateRetailCursorFeedback()
{
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null)

View file

@ -63,6 +63,31 @@ public static class FixtureProvider
manaText: () => "90/100");
break;
case RadarController.LayoutId: // gmRadarUI
RadarController.Bind(
layout,
() => new UiRadarSnapshot(
PlayerHeadingDegrees: 32f,
Blips:
[
new UiRadarBlip(0x50000001u, "Portal to Holtburg", 82f, 43f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.Portal),
AcDream.Core.Ui.RadarBlipShape.Plus),
new UiRadarBlip(0x50000002u, "Drudge Prowler", 43f, 76f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.Creature),
AcDream.Core.Ui.RadarBlipShape.Plus),
new UiRadarBlip(0x50000003u, "Town Crier", 68f, 87f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.NPC),
AcDream.Core.Ui.RadarBlipShape.Box),
new UiRadarBlip(0x50000004u, "Selected PK", 39f, 39f,
RadarColor(AcDream.Core.Ui.RadarBlipColors.PlayerKiller),
AcDream.Core.Ui.RadarBlipShape.X,
Selected: true),
],
CoordinatesText: "42.1N,33.3E"),
datFont: stack.VitalsDatFont);
break;
case 0x21000016u: // toolbar
ToolbarController.Bind(
layout,
@ -156,4 +181,7 @@ public static class FixtureProvider
return handle;
};
private static System.Numerics.Vector4 RadarColor(AcDream.Core.Ui.RadarBlipColors.Rgba color)
=> new(color.Red, color.Green, color.Blue, color.Alpha);
}

View file

@ -75,6 +75,7 @@ public static class DatWidgetFactory
UiElement e = info.Type switch
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter

View file

@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Ui;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the runtime-created behavior of retail <c>gmRadarUI</c> to the static
/// LayoutDesc <c>0x21000074</c> tree. Like the other gm* controllers, this class only
/// finds children by retail id and attaches live providers; it does not recreate DAT chrome.
/// </summary>
public sealed class RadarController
{
public const uint LayoutId = 0x21000074u;
/// <summary>Production layout property 0x1000002D, recovered directly from the retail DAT.</summary>
public const int RadarPixelRadius = 50;
public const uint RootId = 0x100006D3u;
public const uint CoordinateContainerId = 0x1000003Eu;
public const uint RadarDiscId = 0x1000003Fu;
public const uint NorthTokenId = 0x10000040u;
public const uint EastTokenId = 0x10000041u;
public const uint SouthTokenId = 0x10000042u;
public const uint WestTokenId = 0x10000043u;
public const uint LockButtonId = 0x10000619u;
public const uint DragButtonId = 0x100006A3u;
private static readonly Vector4 CoordinateColor = Vector4.One;
private readonly UiRadar _radar;
private readonly UiElement? _coordinateContainer;
private readonly UiText? _coordinateText;
private readonly UiButton? _lockButton;
private readonly UiElement? _dragButton;
private readonly Action<bool>? _setUiLocked;
private readonly CompassToken[] _tokens;
private float _lastHeading = float.NaN;
private string? _lastCoordinates;
private bool _coordinatesInitialized;
private bool? _lastUiLocked;
private RadarController(
ImportedLayout layout,
Func<UiRadarSnapshot> snapshotProvider,
Action<uint>? selectObject,
Action<uint?>? hoveredObjectChanged,
Action<bool>? setUiLocked,
UiDatFont? datFont)
{
_radar = layout.Root as UiRadar
?? throw new ArgumentException(
$"Layout root must be {nameof(UiRadar)} (retail class 0x{UiRadar.RetailClassId:X8}).",
nameof(layout));
_radar.Center = ResolveCenter(layout);
_radar.SnapshotProvider = () =>
{
var snapshot = snapshotProvider() ?? UiRadarSnapshot.Empty;
ApplyPresentation(snapshot);
return snapshot;
};
_radar.SelectObject = selectObject;
_radar.HoveredObjectChanged = hoveredObjectChanged;
_setUiLocked = setUiLocked;
_coordinateContainer = layout.FindElement(CoordinateContainerId);
_coordinateText = CreateCoordinateText(_coordinateContainer, datFont);
_lockButton = layout.FindElement(LockButtonId) as UiButton;
_dragButton = layout.FindElement(DragButtonId);
if (_lockButton is not null && _setUiLocked is not null)
_lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false));
_tokens =
[
CreateToken(layout.FindElement(NorthTokenId), RadarCompassPoint.North, _radar.Center),
CreateToken(layout.FindElement(EastTokenId), RadarCompassPoint.East, _radar.Center),
CreateToken(layout.FindElement(SouthTokenId), RadarCompassPoint.South, _radar.Center),
CreateToken(layout.FindElement(WestTokenId), RadarCompassPoint.West, _radar.Center),
];
// Retail moves the radar only from its dedicated drag affordance. Making that
// imported Type-2 element hit-testable lets UiRoot's existing draggable-window
// machinery move the UiRadar ancestor while ordinary radar clicks select blips.
if (_dragButton is not null)
_dragButton.ClickThrough = false;
_radar.Draggable = true;
_radar.ResizeX = false;
_radar.ResizeY = false;
_radar.Refresh();
}
/// <summary>
/// Bind the projected snapshot provider and interaction callbacks to an imported radar.
/// No external per-frame call is required; <see cref="UiRadar"/> polls at retail's 25 ms cadence.
/// </summary>
public static RadarController Bind(
ImportedLayout layout,
Func<UiRadarSnapshot> snapshotProvider,
Action<uint>? selectObject = null,
Action<uint?>? hoveredObjectChanged = null,
Action<bool>? setUiLocked = null,
UiDatFont? datFont = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(snapshotProvider);
return new RadarController(
layout,
snapshotProvider,
selectObject,
hoveredObjectChanged,
setUiLocked,
datFont);
}
private void ApplyPresentation(UiRadarSnapshot snapshot)
{
if (!snapshot.PlayerHeadingDegrees.Equals(_lastHeading))
{
for (int i = 0; i < _tokens.Length; i++)
{
ref readonly var token = ref _tokens[i];
if (token.Element is null)
continue;
var p = RetailRadar.GetCompassTokenTopLeft(
snapshot.PlayerHeadingDegrees,
token.Point,
_radar.Center,
token.Magnitude,
new Vector2(token.Element.Width, token.Element.Height));
token.Element.Left = p.X;
token.Element.Top = p.Y;
}
_lastHeading = snapshot.PlayerHeadingDegrees;
}
if (!_coordinatesInitialized
|| !string.Equals(snapshot.CoordinatesText, _lastCoordinates, StringComparison.Ordinal))
{
_coordinatesInitialized = true;
_lastCoordinates = snapshot.CoordinatesText;
bool visible = !string.IsNullOrEmpty(snapshot.CoordinatesText);
if (_coordinateContainer is not null)
_coordinateContainer.Visible = visible;
if (_coordinateText is not null)
_coordinateText.Visible = visible;
}
if (_lastUiLocked != snapshot.UiLocked)
{
_lastUiLocked = snapshot.UiLocked;
_radar.Draggable = !snapshot.UiLocked;
if (_dragButton is not null)
_dragButton.Visible = !snapshot.UiLocked;
if (_lockButton is not null)
_lockButton.ActiveState = snapshot.UiLocked ? "LockedUI" : "UnlockedUI";
}
}
private static Vector2 ResolveCenter(ImportedLayout layout)
{
// ElementReader intentionally imports the common LayoutDesc fields only; gmRadarUI's
// custom center property 0x1000002E is not exposed yet. Derive the exact production
// center (60,60) from the real 120x120 disc instead of duplicating it. Radius property
// 0x1000002D is the recovered RadarPixelRadius constant above.
if (layout.FindElement(RadarDiscId) is { } disc)
return new Vector2(disc.Left + disc.Width * 0.5f, disc.Top + disc.Height * 0.5f);
return new Vector2(layout.Root.Width * 0.5f, MathF.Min(120f, layout.Root.Height) * 0.5f);
}
private static CompassToken CreateToken(UiElement? element, RadarCompassPoint point, Vector2 center)
{
if (element is null)
return new CompassToken(null, 0f, point);
var initialCenter = new Vector2(
element.Left + element.Width * 0.5f,
element.Top + element.Height * 0.5f);
return new CompassToken(element, Vector2.Distance(initialCenter, center), point);
}
private UiText? CreateCoordinateText(UiElement? container, UiDatFont? datFont)
{
if (container is null)
return null;
if (container is UiText importedText)
{
// 0x1000003E inherits UIElement_Text from its base layout. Reuse that imported
// behavioral widget so its 0x06004CC0 background stays the actual coordinate
// strip and no duplicate text node is needed.
importedText.Centered = true;
importedText.Padding = 0f;
importedText.DatFont = datFont ?? importedText.DatFont;
importedText.ClickThrough = true;
importedText.AcceptsFocus = false;
importedText.IsEditControl = false;
importedText.CapturesPointerDrag = false;
importedText.LinesProvider = CoordinateLines;
return importedText;
}
var text = new UiText
{
Name = "gmRadarUI.Coordinates",
Left = 0f,
Top = 0f,
Width = container.Width,
Height = container.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
Centered = true,
Padding = 0f,
DatFont = datFont,
ClickThrough = true,
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
ZOrder = int.MaxValue,
LinesProvider = CoordinateLines,
};
container.AddChild(text);
return text;
}
private IReadOnlyList<UiText.Line> CoordinateLines()
=> string.IsNullOrEmpty(_lastCoordinates)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(_lastCoordinates, CoordinateColor) };
private readonly record struct CompassToken(
UiElement? Element,
float Magnitude,
RadarCompassPoint Point);
}

View file

@ -0,0 +1,149 @@
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Joins acdream's existing two-table world model into the immutable projected frame
/// consumed by <see cref="UiRadar"/>. This is the App-layer equivalent of retail's
/// <c>gmRadarUI::DrawObjects</c> object-maintenance walk: Core still owns every
/// AC-specific classification and math decision, while the retained widget remains a
/// backend-only renderer.
/// </summary>
public sealed class RadarSnapshotProvider
{
private static readonly Vector2 ProductionCenter = new(60f, 60f);
private readonly ClientObjectTable _objects;
private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities;
private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns;
private readonly Func<uint> _playerGuid;
private readonly Func<float> _playerYawRadians;
private readonly Func<uint> _playerCellId;
private readonly Func<uint?> _selectedGuid;
private readonly Func<bool> _coordinatesOnRadar;
private readonly Func<bool> _uiLocked;
private readonly Func<uint, RadarRelationshipTraits>? _relationshipFor;
public RadarSnapshotProvider(
ClientObjectTable objects,
IReadOnlyDictionary<uint, WorldEntity> worldEntities,
IReadOnlyDictionary<uint, WorldSession.EntitySpawn> spawns,
Func<uint> playerGuid,
Func<float> playerYawRadians,
Func<uint> playerCellId,
Func<uint?> selectedGuid,
Func<bool> coordinatesOnRadar,
Func<bool> uiLocked,
Func<uint, RadarRelationshipTraits>? relationshipFor = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
_spawns = spawns ?? throw new ArgumentNullException(nameof(spawns));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians));
_playerCellId = playerCellId ?? throw new ArgumentNullException(nameof(playerCellId));
_selectedGuid = selectedGuid ?? throw new ArgumentNullException(nameof(selectedGuid));
_coordinatesOnRadar = coordinatesOnRadar ?? throw new ArgumentNullException(nameof(coordinatesOnRadar));
_uiLocked = uiLocked ?? throw new ArgumentNullException(nameof(uiLocked));
_relationshipFor = relationshipFor;
}
public UiRadarSnapshot BuildSnapshot()
{
bool uiLocked = _uiLocked();
uint playerGuid = _playerGuid();
if (playerGuid == 0u || !_worldEntities.TryGetValue(playerGuid, out var playerEntity))
return UiRadarSnapshot.Empty with { UiLocked = uiLocked };
float heading = MoveToMath.HeadingFromYaw(_playerYawRadians());
uint playerCellId = _playerCellId();
bool isOutside = RadarCoordinates.TryFromCell(playerCellId, out var playerCoordinates);
string? coordinates = _coordinatesOnRadar() && isOutside
? playerCoordinates.CombinedText
: null;
uint playerPwd = _spawns.TryGetValue(playerGuid, out var playerSpawn)
? playerSpawn.ObjectDescriptionFlags ?? 0u
: 0u;
var playerTraits = RadarObjectTraits.FromPublicWeenieDescription(
(uint)(_objects.Get(playerGuid)?.Type ?? ItemType.None), playerPwd);
float range = RetailRadar.GetRangeMeters(isOutside);
var blips = new List<UiRadarBlip>(_worldEntities.Count);
foreach (var pair in _worldEntities)
{
uint guid = pair.Key;
if (guid == playerGuid)
continue;
var entity = pair.Value;
var clientObject = _objects.Get(guid);
_spawns.TryGetValue(guid, out var spawn);
byte? rawBehavior = clientObject?.RadarBehavior ?? spawn.RadarBehavior;
if (rawBehavior is null
|| !RetailRadar.IsShowable((RadarBehavior)rawBehavior.Value, hasPhysicsObject: true))
{
continue;
}
uint itemType = (uint)(clientObject?.Type ?? ItemType.None);
if (itemType == 0u)
itemType = spawn.ItemType ?? 0u;
uint pwd = spawn.ObjectDescriptionFlags ?? 0u;
byte colorOverride = clientObject?.RadarBlipColor ?? spawn.RadarBlipColor ?? 0;
var traits = RadarObjectTraits.FromPublicWeenieDescription(
itemType, pwd, colorOverride);
var relationship = _relationshipFor?.Invoke(guid) ?? default;
relationship = relationship with
{
PlayerIsPlayerKiller = playerTraits.IsPlayerKiller,
PlayerIsPkLite = playerTraits.IsPkLite,
};
var shape = RetailRadar.GetBlipShape(traits, relationship);
if (shape == RadarBlipShape.Undef)
continue;
Vector3 playerSpace = MoveToMath.GlobalToLocalVec(
playerEntity.Rotation,
entity.Position - playerEntity.Position);
if (!RetailRadar.TryProject(
playerSpace,
ProductionCenter,
RadarController.RadarPixelRadius,
range,
out var projection))
{
continue;
}
var color = RadarBlipColors.For(traits, relationship)
.DimRgb(projection.RgbMultiplier);
string? name = clientObject?.Name;
if (string.IsNullOrEmpty(name))
name = spawn.Name ?? $"0x{guid:X8}";
blips.Add(new UiRadarBlip(
ObjectId: guid,
Name: name,
PixelX: projection.Pixel.X,
PixelY: projection.Pixel.Y,
Color: new Vector4(color.Red, color.Green, color.Blue, color.Alpha),
Shape: shape,
Selected: _selectedGuid() == guid));
}
return new UiRadarSnapshot(
PlayerHeadingDegrees: heading,
Blips: blips,
CoordinatesText: coordinates,
BlankBlips: false,
UiLocked: uiLocked);
}
}

View file

@ -156,6 +156,10 @@ public abstract class UiElement
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
public bool Draggable { get; set; }
/// <summary>Clamp a dragged top-level window fully inside its parent. Retail
/// <c>gmRadarUI::MoveTo</c> enables this; most windows retain the toolkit default.</summary>
public bool ConstrainDragToParent { get; set; }
/// <summary>If true, a left-drag starting near this element's edge/corner
/// resizes it (window resize). Intended for top-level panels.</summary>
public bool Resizable { get; set; }

View file

@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Ui;
namespace AcDream.App.UI;
/// <summary>
/// One already-projected radar object. <paramref name="PixelX"/> and
/// <paramref name="PixelY"/> are local pixels in the 120x120 radar disc. World-to-player
/// projection stays in the Core retail port; this record is the backend-facing draw seam.
/// </summary>
public readonly record struct UiRadarBlip(
uint ObjectId,
string Name,
float PixelX,
float PixelY,
Vector4 Color,
RadarBlipShape Shape,
bool Selected = false);
/// <summary>
/// Immutable frame consumed by <see cref="UiRadar"/>. A null
/// <paramref name="CoordinatesText"/> hides retail's coordinate strip (indoors or when
/// CoordinatesOnRadar is disabled). <paramref name="BlankBlips"/> mirrors
/// <c>ClientUISystem::m_bRadarBlank</c>: static chrome and the player marker remain visible.
/// </summary>
public sealed record UiRadarSnapshot(
float PlayerHeadingDegrees,
IReadOnlyList<UiRadarBlip> Blips,
string? CoordinatesText,
bool BlankBlips = false,
bool UiLocked = false)
{
public static UiRadarSnapshot Empty { get; } = new(
0f,
Array.Empty<UiRadarBlip>(),
null);
}
/// <summary>
/// Retained-mode implementation of retail <c>gmRadarUI</c>, registered as class id
/// <c>0x10000010</c>. The imported LayoutDesc supplies all static sprites; this widget
/// draws the dynamic object blips and fixed player marker after those children.
///
/// <para>Retail references:</para>
/// <list type="bullet">
/// <item><c>gmRadarUI::DrawBlip</c> 0x004D8A30 and shape helpers 0x004D7C30-0x004D8531.</item>
/// <item><c>gmRadarUI::DrawChildren</c> 0x004D9720 (static children, objects, player marker).</item>
/// <item><c>gmRadarUI::UseTime</c> 0x004D98A0 (25 ms refresh cadence).</item>
/// </list>
/// </summary>
public sealed class UiRadar : UiElement
{
public const uint RetailClassId = 0x10000010u;
public const float RetailRefreshSeconds = RetailRadar.UpdateIntervalSeconds;
public const float HoverRadiusPixels = 6f;
private static readonly Vector4 PlayerMarkerColor = new(0f, 1f, 0f, 1f);
private UiRadarSnapshot _snapshot = UiRadarSnapshot.Empty;
private double _refreshAccumulator;
private uint? _hoveredObjectId;
private string? _hoveredObjectName;
/// <summary>Local center of the radar disc. Filled by <c>RadarController</c> from LayoutDesc 0x21000074.</summary>
public Vector2 Center { get; set; } = new(60f, 60f);
/// <summary>Polled at retail's 25 ms cadence. The provider performs Core-to-UI adaptation.</summary>
public Func<UiRadarSnapshot>? SnapshotProvider { get; set; }
/// <summary>Invoked when the user clicks the nearest blip under the pointer.</summary>
public Action<uint>? SelectObject { get; set; }
/// <summary>Optional host hook for target highlights/status text.</summary>
public Action<uint?>? HoveredObjectChanged { get; set; }
public UiRadarSnapshot Snapshot => _snapshot;
public uint? HoveredObjectId => _hoveredObjectId;
/// <summary>The radar itself must receive a click inside a draggable window; its dedicated
/// drag-handle child remains a non-HandlesClick target so UiRoot moves the window from there.</summary>
public override bool HandlesClick => true;
/// <summary>Apply one projected frame immediately. Used once by the binder, then by the 25 ms tick.</summary>
public void ApplySnapshot(UiRadarSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
_snapshot = snapshot;
// An object can disappear between pointer events. Clear stale hover immediately so a
// later click cannot select a guid that is no longer in the provider snapshot.
if (_hoveredObjectId is uint hovered)
{
bool stillPresent = false;
for (int i = 0; i < snapshot.Blips.Count; i++)
{
if (snapshot.Blips[i].ObjectId == hovered)
{
stillPresent = true;
break;
}
}
if (!stillPresent)
SetHovered(null, null);
}
}
/// <summary>Poll the provider now, without waiting for the next retained-tree tick.</summary>
public void Refresh()
{
if (SnapshotProvider is { } provider)
ApplySnapshot(provider() ?? UiRadarSnapshot.Empty);
}
protected override void OnTick(double deltaSeconds)
{
if (SnapshotProvider is null)
return;
_refreshAccumulator += deltaSeconds;
if (_refreshAccumulator < RetailRefreshSeconds)
return;
// Retail schedules the next update from Timer::cur_time rather than replaying missed
// ticks. Keep only the sub-interval remainder after a long frame.
_refreshAccumulator %= RetailRefreshSeconds;
Refresh();
}
protected override bool OnHitTest(float localX, float localY)
{
bool inside = base.OnHitTest(localX, localY);
if (!inside)
{
SetHovered(null, null);
return false;
}
UpdateHoveredBlip(localX, localY);
return true;
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.HoverLeave)
{
SetHovered(null, null);
return false;
}
if (e.Type == UiEventType.Click && _hoveredObjectId is uint guid)
{
SelectObject?.Invoke(guid);
return SelectObject is not null;
}
return false;
}
public override string? GetTooltipText() => _hoveredObjectName;
protected override void OnDrawAfterChildren(UiRenderContext ctx)
{
// Retail order is UIRegion::DrawChildren -> DrawObjects -> fixed bright-green player
// marker (gmRadarUI::DrawChildren 0x004D9720). OnDrawAfterChildren gives the retained
// tree that exact composition order without reimplementing any static DAT sprite.
if (!_snapshot.BlankBlips)
{
for (int i = 0; i < _snapshot.Blips.Count; i++)
DrawBlip(ctx, _snapshot.Blips[i]);
}
int cx = RoundPixel(Center.X);
int cy = RoundPixel(Center.Y);
DrawPixels(ctx, cx, cy, PlayerMarkerColor, RetailRadar.PlayerMarkerPixels);
}
private void UpdateHoveredBlip(float localX, float localY)
{
uint? nearestId = null;
string? nearestName = null;
float nearestDistanceSquared = HoverRadiusPixels * HoverRadiusPixels;
if (!_snapshot.BlankBlips)
{
for (int i = 0; i < _snapshot.Blips.Count; i++)
{
var blip = _snapshot.Blips[i];
float dx = localX - blip.PixelX;
float dy = localY - blip.PixelY;
float distanceSquared = dx * dx + dy * dy;
if (distanceSquared <= nearestDistanceSquared)
{
nearestDistanceSquared = distanceSquared;
nearestId = blip.ObjectId;
nearestName = blip.Name;
}
}
}
SetHovered(nearestId, nearestName);
}
private void SetHovered(uint? id, string? name)
{
if (_hoveredObjectId == id && _hoveredObjectName == name)
return;
_hoveredObjectId = id;
_hoveredObjectName = name;
HoveredObjectChanged?.Invoke(id);
}
private static void DrawBlip(UiRenderContext ctx, in UiRadarBlip blip)
{
int x = RoundPixel(blip.PixelX);
int y = RoundPixel(blip.PixelY);
DrawPixels(ctx, x, y, blip.Color, RetailRadar.GetBlipPixels(blip.Shape));
if (blip.Selected)
DrawPixels(ctx, x, y, blip.Color, RetailRadar.SelectionPixels);
}
private static void DrawPixels(
UiRenderContext ctx,
int centerX,
int centerY,
Vector4 color,
ReadOnlySpan<RadarPixelOffset> offsets)
{
for (int i = 0; i < offsets.Length; i++)
ctx.DrawFill(centerX + offsets[i].X, centerY + offsets[i].Y, 1, 1, color);
}
private static int RoundPixel(float value)
=> checked((int)MathF.Round(value, MidpointRounding.ToEven));
}

View file

@ -68,6 +68,10 @@ public sealed class UiRoot : UiElement
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
public bool WantsKeyboard => KeyboardFocus is not null;
/// <summary>Retail PlayerModule::LockUI gate. Blocks all retained-window
/// move/resize interactions without disabling their buttons or content.</summary>
public bool UiLocked { get; set; }
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
@ -79,7 +83,7 @@ public sealed class UiRoot : UiElement
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
return window is { Resizable: true }
return !UiLocked && window is { Resizable: true }
? HitEdges(window, MouseX, MouseY, ResizeGrip)
: ResizeEdges.None;
}
@ -90,7 +94,7 @@ public sealed class UiRoot : UiElement
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
if (target is null || window is not { Draggable: true })
if (UiLocked || target is null || window is not { Draggable: true })
return false;
if (HoverResizeEdges != ResizeEdges.None)
return false;
@ -141,6 +145,9 @@ public sealed class UiRoot : UiElement
/// <summary>Raised when a drag is released over no UI element.</summary>
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi;
/// <summary>Raised after a registered top-level window finishes moving.</summary>
public event Action<string, UiElement>? WindowMoved;
private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child)
@ -221,8 +228,16 @@ public sealed class UiRoot : UiElement
// Window-move drag takes precedence over drag-drop / hover / fall-through.
if (_windowDragTarget is not null)
{
_windowDragTarget.Left = x - _windowDragOffX;
_windowDragTarget.Top = y - _windowDragOffY;
float left = x - _windowDragOffX;
float top = y - _windowDragOffY;
if (_windowDragTarget.ConstrainDragToParent
&& _windowDragTarget.Parent is { } parent)
{
left = Math.Clamp(left, 0f, Math.Max(0f, parent.Width - _windowDragTarget.Width));
top = Math.Clamp(top, 0f, Math.Max(0f, parent.Height - _windowDragTarget.Height));
}
_windowDragTarget.Left = left;
_windowDragTarget.Top = top;
return;
}
@ -285,7 +300,7 @@ public sealed class UiRoot : UiElement
var window = FindWindow(target);
// Retail-faithful: pressing on a window raises it above its peers.
if (window is not null) BringToFront(window);
if (btn == UiMouseButton.Left && window is not null)
if (btn == UiMouseButton.Left && window is not null && !UiLocked)
{
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
if (edges != ResizeEdges.None)
@ -370,8 +385,15 @@ public sealed class UiRoot : UiElement
if (_windowDragTarget is not null)
{
var movedWindow = _windowDragTarget;
_windowDragTarget = null;
ReleaseCapture();
foreach (var pair in _windows)
if (ReferenceEquals(pair.Value, movedWindow))
{
WindowMoved?.Invoke(pair.Key, movedWindow);
break;
}
return;
}

View file

@ -9,4 +9,5 @@ public static class WindowNames
public const string Character = "character";
public const string Inventory = "inventory";
public const string Chat = "chat";
public const string Radar = "radar";
}

View file

@ -187,7 +187,13 @@ public static class CreateObject
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null);
float? Workmanship = null,
// PublicWeenieDesc optional-tail bytes (_blipColor/_radar_enum at
// acclient.h:37191-37192; retail UnPack 0x005AD470 reads both as u8).
// Nullable preserves the wire distinction between an absent flag and
// an explicitly transmitted zero (the enum's undefined/default value).
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -720,8 +726,8 @@ public static class CreateObject
// 0x00010000 ValidLocations u32 (skip)
// 0x00020000 CurrentlyWieldedLocation u32 (skip)
// 0x00040000 Priority u32 (skip)
// 0x00100000 RadarBlipColor u8 (skip)
// 0x00800000 RadarBehavior u8 (skip)
// 0x00100000 RadarBlipColor u8 CAPTURE
// 0x00800000 RadarBehavior u8 CAPTURE
// 0x08000000 PScript u16 (skip)
// 0x01000000 Workmanship f32 (skip)
// 0x00200000 Burden u16 (skip)
@ -742,6 +748,8 @@ public static class CreateObject
uint? useability = null;
float? useRadius = null;
uint? targetType = null;
byte? radarBlipColor = null;
byte? radarBehavior = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
@ -864,12 +872,12 @@ public static class CreateObject
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
pos += 1;
radarBlipColor = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
pos += 1;
radarBehavior = body[pos]; pos += 1;
}
if ((weenieFlags & 0x08000000u) != 0) // PScript u16
{
@ -957,7 +965,8 @@ public static class CreateObject
ContainerId: wContainerId, WielderId: wWielderId,
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
Workmanship: wWorkmanship);
Workmanship: wWorkmanship,
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).

View file

@ -83,5 +83,7 @@ public static class ObjectTableWiring
MaxStructure: s.MaxStructure,
Workmanship: s.Workmanship,
Useability: s.Useability,
TargetType: s.TargetType);
TargetType: s.TargetType,
RadarBlipColor: s.RadarBlipColor,
RadarBehavior: s.RadarBehavior);
}

View file

@ -116,7 +116,63 @@ public sealed class WorldSession : IDisposable
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
ushort InstanceSequence = 0,
ushort MovementSequence = 0,
ushort ServerControlSequence = 0);
ushort ServerControlSequence = 0,
// PublicWeenieDesc optional-tail bytes. null means the corresponding
// flag was absent; zero means the server explicitly sent the enum's
// undefined/default value.
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
/// <summary>
/// Projects the wire-level CreateObject result into the stable session
/// event payload. Kept as a focused seam so every optional field's
/// absent-versus-explicit-default semantics can be tested without a UDP
/// session.
/// </summary>
internal static EntitySpawn ToEntitySpawn(CreateObject.Parsed parsed) => new(
parsed.Guid,
parsed.Position,
parsed.SetupTableId,
parsed.AnimPartChanges,
parsed.TextureChanges,
parsed.SubPalettes,
parsed.BasePaletteId,
parsed.ObjScale,
parsed.Name,
parsed.ItemType,
parsed.MotionState,
parsed.MotionTableId,
parsed.PhysicsState,
parsed.ObjectDescriptionFlags,
parsed.Friction,
parsed.Elasticity,
parsed.Useability,
parsed.UseRadius,
parsed.TargetType,
parsed.IconId,
parsed.IconOverlayId,
parsed.IconUnderlayId,
parsed.UiEffects,
parsed.WeenieClassId,
parsed.Value,
parsed.StackSize,
parsed.StackSizeMax,
parsed.Burden,
parsed.ItemsCapacity,
parsed.ContainersCapacity,
parsed.ContainerId,
parsed.WielderId,
parsed.ValidLocations,
parsed.CurrentWieldedLocation,
parsed.Priority,
parsed.Structure,
parsed.MaxStructure,
parsed.Workmanship,
InstanceSequence: parsed.InstanceSequence,
MovementSequence: parsed.MovementSequence,
ServerControlSequence: parsed.ServerControlSequence,
RadarBlipColor: parsed.RadarBlipColor,
RadarBehavior: parsed.RadarBehavior);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
@ -799,48 +855,7 @@ public sealed class WorldSession : IDisposable
_forcePositionSequence = parsed.Value.ForcePositionSequence;
}
EntitySpawned?.Invoke(new EntitySpawn(
parsed.Value.Guid,
parsed.Value.Position,
parsed.Value.SetupTableId,
parsed.Value.AnimPartChanges,
parsed.Value.TextureChanges,
parsed.Value.SubPalettes,
parsed.Value.BasePaletteId,
parsed.Value.ObjScale,
parsed.Value.Name,
parsed.Value.ItemType,
parsed.Value.MotionState,
parsed.Value.MotionTableId,
parsed.Value.PhysicsState,
parsed.Value.ObjectDescriptionFlags,
parsed.Value.Friction,
parsed.Value.Elasticity,
parsed.Value.Useability,
parsed.Value.UseRadius,
parsed.Value.TargetType,
parsed.Value.IconId,
parsed.Value.IconOverlayId,
parsed.Value.IconUnderlayId,
parsed.Value.UiEffects,
parsed.Value.WeenieClassId,
parsed.Value.Value,
parsed.Value.StackSize,
parsed.Value.StackSizeMax,
parsed.Value.Burden,
parsed.Value.ItemsCapacity,
parsed.Value.ContainersCapacity,
parsed.Value.ContainerId,
parsed.Value.WielderId,
parsed.Value.ValidLocations,
parsed.Value.CurrentWieldedLocation,
parsed.Value.Priority,
parsed.Value.Structure,
parsed.Value.MaxStructure,
parsed.Value.Workmanship,
InstanceSequence: parsed.Value.InstanceSequence,
MovementSequence: parsed.Value.MovementSequence,
ServerControlSequence: parsed.Value.ServerControlSequence));
EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
}
}
else if (op == DeleteObject.Opcode)

View file

@ -178,6 +178,10 @@ public sealed class ClientObject
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
/// <summary>Retail PublicWeenieDesc <c>_blipColor</c>; null until supplied by CreateObject.</summary>
public byte? RadarBlipColor { get; set; }
/// <summary>Retail PublicWeenieDesc <c>_radar_enum</c>; null until supplied by CreateObject.</summary>
public byte? RadarBehavior { get; set; }
public int Structure { get; set; } // charges/uses remaining
public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
@ -217,7 +221,9 @@ public readonly record struct WeenieData(
int? MaxStructure,
float? Workmanship,
uint? Useability = null,
uint? TargetType = null);
uint? TargetType = null,
byte? RadarBlipColor = null,
byte? RadarBehavior = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).

View file

@ -386,6 +386,8 @@ public sealed class ClientObjectTable
if (d.Priority is { } pr) obj.Priority = pr;
if (d.Useability is { } use) obj.Useability = use;
if (d.TargetType is { } targetType) obj.TargetType = targetType;
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
if (d.Structure is { } st) obj.Structure = st;

View file

@ -3,91 +3,190 @@ using AcDream.Core.Items;
namespace AcDream.Core.Ui;
/// <summary>
/// B.7 (2026-05-15) — port of retail's <c>gmRadarUI::GetBlipColor</c>
/// (named decomp <c>0x004d76f0</c>). Returns the radar / target-indicator
/// colour for an entity based on its <see cref="ItemType"/> and the
/// raw <c>PublicWeenieDesc._bitfield</c> we already parse out of
/// <c>CreateObject</c>.
///
/// <para>
/// Used by the Vivid Target Indicator (Phase B.7) to colour the four
/// corner triangles around the selected entity. Same value retail
/// would have shown on the radar blip — so the indicator + radar agree.
/// </para>
///
/// <para>
/// Dispatch order matches the retail decomp at lines 219913+ of
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>. The
/// PWD bit layout matches <c>acclient.h:6431-6463</c>:
/// <list type="bullet">
/// <item><c>BF_PLAYER = 0x8</c></item>
/// <item><c>BF_PLAYER_KILLER = 0x20</c></item>
/// <item><c>BF_VENDOR = 0x200</c> (byte[1] &amp; 0x02 in retail)</item>
/// <item><c>BF_PORTAL = 0x40000</c></item>
/// <item><c>BF_FREE_PKSTATUS = 0x200000</c> (hostile-flagged player)</item>
/// <item><c>BF_PKLITE_PKSTATUS = 0x2000000</c></item>
/// </list>
/// </para>
///
/// <para>
/// <b>RGBA values</b> are hand-tuned to visually match retail screenshots
/// (yellow creature, red PK, pink PKLite, green vendor, cyan portal,
/// white default). Real <c>RGBAColor_Radar*</c> constants live in retail
/// static data — if they're ever recovered the table can be tightened.
/// </para>
/// Facts used by retail's radar color/shape classification. Relationship state is
/// intentionally separate because fellowship and allegiance membership do not come
/// from <c>PublicWeenieDesc</c>.
/// </summary>
public readonly record struct RadarObjectTraits(
bool IsValid = true,
byte BlipColorOverride = 0,
bool IsPortal = false,
bool IsVendor = false,
bool IsAttackable = false,
bool IsCreature = false,
bool IsPlayer = false,
bool IsAdmin = false,
bool IsHiddenAdmin = false,
bool IsPlayerKiller = false,
bool IsPkLite = false,
bool IsFreePk = false)
{
// 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;
/// <summary>
/// Build the subset of retail traits present in a CreateObject PWD. Fellowship
/// and allegiance state must still be supplied separately.
/// </summary>
public static RadarObjectTraits FromPublicWeenieDescription(
uint itemType,
uint bitfield,
byte blipColorOverride = 0)
=> new(
IsValid: (bitfield & 0x80000000u) == 0,
BlipColorOverride: blipColorOverride,
IsPortal: (bitfield & BfPortal) != 0,
IsVendor: (bitfield & BfVendor) != 0,
IsAttackable: (bitfield & BfAttackable) != 0,
IsCreature: (itemType & (uint)ItemType.Creature) != 0,
IsPlayer: (bitfield & BfPlayer) != 0,
IsAdmin: (bitfield & BfAdmin) != 0,
IsHiddenAdmin: (bitfield & BfHiddenAdmin) != 0,
IsPlayerKiller: (bitfield & BfPlayerKiller) != 0,
IsPkLite: (bitfield & BfPkLite) != 0,
IsFreePk: (bitfield & BfFreePk) != 0);
}
/// <summary>Relationship facts owned by the client player/fellowship systems.</summary>
public readonly record struct RadarRelationshipTraits(
bool IsFellowshipMember = false,
bool IsFellowshipLeader = false,
bool IsAllegianceMember = false,
bool PlayerIsPlayerKiller = false,
bool PlayerIsPkLite = false);
/// <summary>
/// Exact retail radar palette and <c>gmRadarUI::GetBlipColor</c> dispatch.
/// Named retail anchors: <c>gmRadarUI::GetBlipColor @ 0x004D76F0</c> and
/// <c>RGBAColor_Radar* @ 0x008190E8</c>.
/// </summary>
public static class RadarBlipColors
{
public readonly record struct Rgba(byte R, byte G, byte B, byte A);
/// <summary>
/// Retail stores radar colors as four floats. The full names expose those exact
/// channels; R/G/B/A are compatibility RGBA8 projections for older UI callers.
/// </summary>
public readonly record struct Rgba(float Red, float Green, float Blue, float Alpha)
{
public byte R => ToByte(Red);
public byte G => ToByte(Green);
public byte B => ToByte(Blue);
public byte A => ToByte(Alpha);
public static readonly Rgba Item = new(220, 220, 220, 255); // light grey (default object)
public static readonly Rgba Default = new(255, 255, 255, 255); // white (friendly player)
public static readonly Rgba Creature = new(255, 220, 80, 255); // yellow (NPC / monster)
public static readonly Rgba PlayerKiller = new(255, 64, 64, 255); // red (PK)
public static readonly Rgba PKLite = new(255, 128, 192, 255); // pink (PKLite)
public static readonly Rgba Vendor = new( 64, 192, 64, 255); // green (vendor NPC)
public static readonly Rgba Portal = new( 64, 192, 255, 255); // cyan (portal)
public Rgba DimRgb(float multiplier)
=> new(Red * multiplier, Green * multiplier, Blue * multiplier, Alpha);
/// <summary>ImGui/OpenGL-style packed <c>0xAABBGGRR</c> projection.</summary>
public uint ToAbgr32()
=> ((uint)A << 24) | ((uint)B << 16) | ((uint)G << 8) | R;
private static byte ToByte(float value)
=> (byte)Math.Clamp((int)MathF.Round(value * 255f), 0, 255);
}
// Verbatim float constants at acclient.exe 0x008190E8..0x00819184.
public static readonly Rgba Blue = new(0.25f, 0.660000026f, 1f, 1f);
public static readonly Rgba Gold = new(1f, 0.670000017f, 0f, 1f);
public static readonly Rgba Yellow = new(1f, 1f, 0.5f, 1f);
public static readonly Rgba White = new(1f, 1f, 1f, 1f);
public static readonly Rgba Red = new(1f, 0.25f, 0.389999986f, 1f);
public static readonly Rgba Purple = new(0.75f, 0.389999986f, 1f, 1f);
public static readonly Rgba Pink = new(1f, 0.660000026f, 0.75f, 1f);
public static readonly Rgba Green = new(0f, 0.5f, 0.25f, 1f);
public static readonly Rgba Cyan = new(0f, 1f, 1f, 1f);
public static readonly Rgba BrightGreen = new(0f, 1f, 0f, 1f);
// Retail role aliases initialized at 0x006EC780..0x006F90E7.
public static readonly Rgba Default = White;
public static readonly Rgba Item = White;
public static readonly Rgba Admin = Cyan;
public static readonly Rgba Advocate = Pink;
public static readonly Rgba Creature = Gold;
public static readonly Rgba LifeStone = Blue;
public static readonly Rgba NPC = Yellow;
public static readonly Rgba PlayerKiller = Red;
public static readonly Rgba Portal = Purple;
public static readonly Rgba Sentinel = Cyan;
public static readonly Rgba Vendor = Yellow;
public static readonly Rgba Fellowship = BrightGreen;
public static readonly Rgba FellowshipLeader = BrightGreen;
public static readonly Rgba PKLite = Pink;
/// <summary>
/// Resolve the radar-blip colour for an entity. Caller supplies the
/// raw <see cref="ItemType"/> (from <c>CreateObject.ItemType</c>) and
/// <paramref name="pwdBitfield"/> (from
/// <c>CreateObject.ObjectDescriptionFlags</c>) — both are already
/// parsed and stashed on <c>EntitySpawn</c> at spawn time.
///
/// <para>
/// Returns <see cref="Default"/> for friendly players, <see cref="Creature"/>
/// for NPCs/monsters, <see cref="Item"/> for everything else.
/// Special types (PK, PKLite, Vendor, Portal) win over the base
/// type when their flag is set.
/// </para>
/// Compatibility entry point for metadata already surfaced by CreateObject.
/// Classification which requires fellowship state is available through the
/// explicit-traits overload.
/// </summary>
public static Rgba For(uint itemType, uint pwdBitfield)
=> For(RadarObjectTraits.FromPublicWeenieDescription(itemType, pwdBitfield));
/// <summary>
/// Port of <c>gmRadarUI::GetBlipColor @ 0x004D76F0</c>. An explicit wire
/// override wins; otherwise portal/vendor/creature/player classification runs
/// in retail order, followed by the fellowship override.
/// </summary>
public static Rgba For(
RadarObjectTraits traits,
RadarRelationshipTraits relationship = default)
{
// Special-type early returns. Order matches retail dispatch
// (Portal first, then Vendor) — same target can't logically
// be both, but in case of bit collision retail's order wins.
if ((pwdBitfield & 0x40000u) != 0) return Portal;
if ((pwdBitfield & 0x200u) != 0) return Vendor;
if (!traits.IsValid)
return Default;
bool isCreature = (itemType & (uint)ItemType.Creature) != 0;
bool isPlayer = (pwdBitfield & 0x8u) != 0;
if (traits.BlipColorOverride != 0)
return ForOverride(traits.BlipColorOverride);
// Creature that isn't a player → NPC / monster → yellow.
if (isCreature && !isPlayer)
if (traits.IsPortal)
return Portal;
if (traits.IsVendor)
return Vendor;
if (traits.IsAttackable && traits.IsCreature && !traits.IsPlayer)
return Creature;
if (isPlayer)
{
bool isPK = (pwdBitfield & 0x20u) != 0;
bool isPKLite = (pwdBitfield & 0x2000000u) != 0;
if (isPK) return PlayerKiller;
if (isPKLite) return PKLite;
if (!traits.IsPlayer)
return Default;
}
// Not a special type, not a creature, not a player → an item /
// object on the ground or in the world.
return Item;
Rgba color = Default;
if (traits.IsAdmin && !traits.IsHiddenAdmin)
color = Admin;
else if (traits.IsPlayerKiller)
color = PlayerKiller;
else if (traits.IsPkLite)
color = PKLite;
else if (traits.IsFreePk)
color = Creature;
if (relationship.IsFellowshipLeader)
color = FellowshipLeader;
else if (relationship.IsFellowshipMember)
color = Fellowship;
return color;
}
/// <summary>Retail PWD <c>_blipColor</c> override table (values 1..10).</summary>
public static Rgba ForOverride(byte overrideValue)
=> overrideValue switch
{
1 => Blue,
2 => Gold,
3 => White,
4 => Purple,
5 => Red,
6 => Pink,
7 => Green,
8 => Yellow,
9 => Cyan,
10 => BrightGreen,
_ => Default,
};
}

View file

@ -0,0 +1,275 @@
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Ui;
/// <summary>Verbatim retail <c>RadarEnum</c> values from <c>acclient.h:6467</c>.</summary>
public enum RadarBehavior : byte
{
Undef = 0,
ShowNever = 1,
ShowMovement = 2,
ShowAttacking = 3,
ShowAlways = 4,
}
/// <summary>Verbatim retail <c>RadarBlipShape</c> values from <c>acclient.h:6575</c>.</summary>
public enum RadarBlipShape
{
Undef = 0,
Circle = 1,
Box = 2,
X = 3,
Plus = 4,
Triangle = 5,
InvertedTriangle = 6,
XBox = 7,
Default = Plus,
AllegianceMember = Box,
FellowshipLeader = Triangle,
Fellowship = InvertedTriangle,
Threat = X,
ThreatAllegiance = XBox,
}
public enum RadarCompassPoint
{
North,
East,
South,
West,
}
public readonly record struct RadarPixelOffset(int X, int Y);
public readonly record struct RadarProjection(Vector2 Pixel, float RgbMultiplier);
/// <summary>
/// Pure retail radar behavior used by the retained UI. The caller supplies positions
/// already converted to player space, matching retail's
/// <c>SmartBox::convert_to_player_space</c> call.
/// </summary>
public static class RetailRadar
{
public const float OutdoorRangeMeters = 75f;
public const float IndoorRangeMeters = 25f;
public const float AltitudeDimThresholdMeters = 5f;
public const float AltitudeDimMultiplier = 0.65f;
public const float UpdateIntervalSeconds = 0.025f;
private static readonly RadarPixelOffset[] PointPixels = [new(0, 0)];
private static readonly RadarPixelOffset[] EdgePixels = [new(0, -1), new(0, 1), new(-1, 0), new(1, 0)];
private static readonly RadarPixelOffset[] CornerPixels = [new(1, 1), new(-1, -1), new(-1, 1), new(1, -1)];
private static readonly RadarPixelOffset[] BoxPixels = [.. EdgePixels, .. CornerPixels];
private static readonly RadarPixelOffset[] XPixels = [new(0, 0), .. CornerPixels];
private static readonly RadarPixelOffset[] PlusPixels = [new(0, 0), .. EdgePixels];
private static readonly RadarPixelOffset[] TrianglePixels = [new(0, 0), new(-1, 1), new(0, 1), new(1, 1)];
private static readonly RadarPixelOffset[] InvertedTrianglePixels = [new(0, 0), new(-1, -1), new(0, -1), new(1, -1)];
private static readonly RadarPixelOffset[] XBoxPixels =
[
.. EdgePixels,
.. CornerPixels,
new(-2, -2),
new(2, -2),
new(-2, 2),
new(2, 2),
];
private static readonly RadarPixelOffset[] PlayerMarkerPixelArray =
[
new(0, 0),
.. EdgePixels,
new(-2, 0),
new(2, 0),
new(0, -2),
new(0, 2),
];
private static readonly RadarPixelOffset[] SelectionPixelArray = BuildSelectionPixels();
/// <summary>
/// <c>ACCWeenieObject::InqShowableOnRadar @ 0x0058C250</c>. The enum names do
/// not imply a live motion/attack test here: retail accepts all three values.
/// </summary>
public static bool IsShowable(RadarBehavior behavior, bool hasPhysicsObject)
=> hasPhysicsObject && behavior is
RadarBehavior.ShowMovement or RadarBehavior.ShowAttacking or RadarBehavior.ShowAlways;
/// <summary><c>gmRadarUI::GetBlipShape @ 0x004D7B60</c>.</summary>
public static RadarBlipShape GetBlipShape(
RadarObjectTraits target,
RadarRelationshipTraits relationship = default)
{
if (!target.IsValid)
return RadarBlipShape.Undef;
if (relationship.IsFellowshipLeader)
return RadarBlipShape.FellowshipLeader;
if (relationship.IsFellowshipMember)
return RadarBlipShape.Fellowship;
if (relationship.IsAllegianceMember)
return RadarBlipShape.AllegianceMember;
if ((target.IsPlayerKiller && relationship.PlayerIsPlayerKiller) ||
(target.IsPkLite && relationship.PlayerIsPkLite))
{
return RadarBlipShape.Threat;
}
return RadarBlipShape.Default;
}
/// <summary>
/// Exact points filled by retail's DrawPoint/Edges/Corners/Cross/X/Triangle/
/// InvertedTriangle/Hollow/XBox functions at <c>0x004D7C30..0x004D8531</c>.
/// </summary>
public static ReadOnlySpan<RadarPixelOffset> GetBlipPixels(RadarBlipShape shape)
=> shape switch
{
RadarBlipShape.Circle => PointPixels,
RadarBlipShape.Box => BoxPixels,
RadarBlipShape.X => XPixels,
RadarBlipShape.Plus => PlusPixels,
RadarBlipShape.Triangle => TrianglePixels,
RadarBlipShape.InvertedTriangle => InvertedTrianglePixels,
RadarBlipShape.XBox => XBoxPixels,
_ => [],
};
/// <summary>
/// Fixed bright-green player marker drawn by <c>gmRadarUI::DrawChildren
/// @ 0x004D9720</c>: a plus with four extra points two pixels from center.
/// </summary>
public static ReadOnlySpan<RadarPixelOffset> PlayerMarkerPixels => PlayerMarkerPixelArray;
/// <summary>Selected-object outline from <c>gmRadarUI::DrawSelected @ 0x004D7FE0</c>.</summary>
public static ReadOnlySpan<RadarPixelOffset> SelectionPixels => SelectionPixelArray;
public static float GetRangeMeters(bool isOutside)
=> isOutside ? OutdoorRangeMeters : IndoorRangeMeters;
/// <summary>
/// Project a player-local position to radar pixels exactly as
/// <c>gmRadarUI::DrawObjects @ 0x004D9380</c>. Objects at or beyond
/// <c>(range - 1 m)</c> are excluded. Positive player-space Y is screen-up.
/// </summary>
public static bool TryProject(
Vector3 playerSpaceMeters,
Vector2 centerPixels,
int radarRadiusPixels,
float radarRangeMeters,
out RadarProjection projection)
{
float inclusionRadius = radarRangeMeters - 1f;
float horizontalDistanceSquared =
(playerSpaceMeters.X * playerSpaceMeters.X) +
(playerSpaceMeters.Y * playerSpaceMeters.Y);
if (horizontalDistanceSquared >= inclusionRadius * inclusionRadius)
{
projection = default;
return false;
}
float scale = radarRadiusPixels / radarRangeMeters;
projection = new RadarProjection(
new Vector2(
(int)(centerPixels.X + (playerSpaceMeters.X * scale)),
(int)(centerPixels.Y - (playerSpaceMeters.Y * scale))),
GetAltitudeRgbMultiplier(playerSpaceMeters.Z));
return true;
}
/// <summary>RGB is dimmed at exactly 5 m; alpha is left unchanged by the caller.</summary>
public static float GetAltitudeRgbMultiplier(float playerSpaceZMeters)
=> MathF.Abs(playerSpaceZMeters) < AltitudeDimThresholdMeters
? 1f
: AltitudeDimMultiplier;
/// <summary>
/// Compute a compass token's top-left pixel from
/// <c>gmRadarUI::UpdateCompassTokens @ 0x004D9060</c>. Heading is the physical
/// player's heading in degrees, not camera yaw.
/// </summary>
public static Vector2 GetCompassTokenTopLeft(
float playerHeadingDegrees,
RadarCompassPoint point,
Vector2 radarCenterPixels,
float tokenMagnitudePixels,
Vector2 tokenSizePixels)
{
// Retail stores headingRadians as float, then performs the trig on x87.
// Preserve that float rounding while using double sin/cos like the x87 path.
float headingRadians = playerHeadingDegrees * 0.0174532924f;
double angle = headingRadians + (point switch
{
RadarCompassPoint.North => Math.PI,
RadarCompassPoint.East => (double)1.57079637f,
RadarCompassPoint.South => 0d,
RadarCompassPoint.West => (double)4.71238899f,
_ => 0d,
});
float offsetX = (float)(Math.Sin(angle) * tokenMagnitudePixels);
float tokenCenterX = (float)(offsetX + radarCenterPixels.X);
float tokenCenterY = (float)((Math.Cos(angle) * tokenMagnitudePixels) + radarCenterPixels.Y);
return new Vector2(
(int)(tokenCenterX - (tokenSizePixels.X * 0.5f)),
(int)(tokenCenterY - (tokenSizePixels.Y * 0.5f)));
}
private static RadarPixelOffset[] BuildSelectionPixels()
{
var pixels = new RadarPixelOffset[20];
int index = 0;
for (int x = -2; x <= 2; x++)
{
pixels[index++] = new RadarPixelOffset(x, 3);
pixels[index++] = new RadarPixelOffset(x, -3);
}
for (int y = -2; y <= 2; y++)
{
pixels[index++] = new RadarPixelOffset(3, y);
pixels[index++] = new RadarPixelOffset(-3, y);
}
return pixels;
}
}
/// <summary>Retail outdoor coordinate text derived from the player's landcell.</summary>
public readonly record struct RadarCoordinates(double X, double Y)
{
public string XText => FormatAxis(X, "W", "E");
public string YText => FormatAxis(Y, "S", "N");
public string CombinedText => $"{YText},{XText}";
/// <summary>
/// Port of <c>CPlayerSystem::InqPlayerCoords @ 0x00560090</c>. Indoor cells
/// fail because retail obtains coordinates through <c>get_landscape_coord</c>.
/// </summary>
public static bool TryFromCell(uint cellId, out RadarCoordinates coordinates)
{
if (!LandDefs.GidToLcoord(cellId, out int lx, out int ly))
{
coordinates = default;
return false;
}
coordinates = new RadarCoordinates(
((lx - 1024) * 0.1) + 0.5,
((ly - 1024) * 0.1) + 0.5);
return true;
}
private static string FormatAxis(double value, string negativeSuffix, string positiveSuffix)
{
string suffix = value < 0d ? negativeSuffix : value > 0d ? positiveSuffix : string.Empty;
return $"{Math.Abs(value).ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}{suffix}";
}
}

View file

@ -51,7 +51,9 @@ public sealed record GameplaySettings(
ShowTooltips: true,
VividTargetingIndicator: false,
SideBySideVitals: false,
CoordinatesOnRadar: false,
// Retail default character-options mask 0x50C4A54A includes
// CoordinatesOnRadar (0x00400000).
CoordinatesOnRadar: true,
SpellDuration: true,
AllowGive: true,
ShowHelm: true,

View file

@ -7,6 +7,9 @@ using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>Per-character retained-window position in screen pixels.</summary>
public readonly record struct UiWindowPosition(float X, float Y);
/// <summary>
/// JSON-backed persistence for non-keybind settings (Display today; future
/// tabs Audio / Gameplay / Chat / Character will be added to the same
@ -290,6 +293,70 @@ public sealed class SettingsStore
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
/// <summary>Load a named retained-window position for one character.</summary>
public UiWindowPosition? LoadWindowPosition(string toonKey, string windowName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
if (!File.Exists(_path)) return null;
try
{
var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
var node = root?["windowPositions"]?[toonKey]?[windowName] as JsonObject;
if (node is null) return null;
float? x = node["x"]?.GetValue<float>();
float? y = node["y"]?.GetValue<float>();
return x is not null && y is not null ? new UiWindowPosition(x.Value, y.Value) : null;
}
catch (Exception ex)
{
Console.WriteLine($"settings: failed to load window position from {_path}: {ex.Message}");
return null;
}
}
/// <summary>
/// Save a named retained-window position under
/// <c>windowPositions[toonKey][windowName]</c>, preserving all settings sections.
/// </summary>
public void SaveWindowPosition(string toonKey, string windowName, UiWindowPosition position)
{
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
JsonObject root;
if (File.Exists(_path))
{
try { root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); }
catch { root = new JsonObject(); }
}
else
{
root = new JsonObject();
}
if (root["windowPositions"] is not JsonObject positions)
{
positions = new JsonObject();
root["windowPositions"] = positions;
}
if (positions[toonKey] is not JsonObject toon)
{
toon = new JsonObject();
positions[toonKey] = toon;
}
toon[windowName] = new JsonObject
{
["x"] = position.X,
["y"] = position.Y,
};
root["version"] = CurrentSchemaVersion;
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)
=> new(StringComparer.Ordinal)
{