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

@ -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}";
}
}