feat(B.7): RadarBlipColors — port of gmRadarUI::GetBlipColor

Static helper resolving a target indicator / radar blip colour from
ItemType + the raw PublicWeenieDesc._bitfield acdream already parses
onto EntitySpawn. Dispatch order matches retail decomp at 0x004d76f0:

  Portal (BF_PORTAL = 0x40000)              → cyan
  Vendor (BF_VENDOR = 0x200)                → green
  Creature && !IsPlayer                     → yellow
  Player + IsPK (BF_PLAYER_KILLER = 0x20)   → red
  Player + IsPKLite (= 0x2000000)           → pink
  Player (other)                            → white (Default)
  Otherwise (item / object)                 → light grey

RGBA values are hand-tuned to visually match retail screenshots; the
real RGBAColor_Radar* constants live in retail static data and can be
swapped in later without breaking call sites.

8 unit tests cover the full type/flag matrix (item, NPC, friendly
player, PK, PKLite, vendor, portal-priority-over-flags).

Next: TargetIndicatorPanel (App, ImGui draw) that uses this lookup.
This commit is contained in:
Erik 2026-05-15 06:49:46 +02:00
parent 37177a418e
commit 8544a785d7
2 changed files with 176 additions and 0 deletions

View file

@ -0,0 +1,93 @@
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>
/// </summary>
public static class RadarBlipColors
{
public readonly record struct Rgba(byte R, byte G, byte B, byte A);
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)
/// <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>
/// </summary>
public static Rgba For(uint itemType, uint pwdBitfield)
{
// 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;
bool isCreature = (itemType & (uint)ItemType.Creature) != 0;
bool isPlayer = (pwdBitfield & 0x8u) != 0;
// Creature that isn't a player → NPC / monster → yellow.
if (isCreature && !isPlayer)
return Creature;
if (isPlayer)
{
bool isPK = (pwdBitfield & 0x20u) != 0;
bool isPKLite = (pwdBitfield & 0x2000000u) != 0;
if (isPK) return PlayerKiller;
if (isPKLite) return PKLite;
return Default;
}
// Not a special type, not a creature, not a player → an item /
// object on the ground or in the world.
return Item;
}
}

View file

@ -0,0 +1,83 @@
using AcDream.Core.Items;
using AcDream.Core.Ui;
using Xunit;
namespace AcDream.Core.Tests.Ui;
public sealed class RadarBlipColorsTests
{
// PWD bit constants per docs/research/named-retail/acclient.h:6431-6463
private const uint BF_PLAYER = 0x8u;
private const uint BF_PLAYER_KILLER = 0x20u;
private const uint BF_VENDOR = 0x200u;
private const uint BF_PORTAL = 0x40000u;
private const uint BF_PKLITE_PKSTATUS = 0x2000000u;
[Fact]
public void Item_NoFlags_ReturnsItemColor()
{
// SpellComponents is itemType=0x1000 (e.g. a Taper) — not a creature.
var result = RadarBlipColors.For(itemType: (uint)ItemType.SpellComponents, pwdBitfield: 0);
Assert.Equal(RadarBlipColors.Item, result);
}
[Fact]
public void Misc_NoFlags_ReturnsItemColor()
{
var result = RadarBlipColors.For((uint)ItemType.Misc, pwdBitfield: 0);
Assert.Equal(RadarBlipColors.Item, result);
}
[Fact]
public void Creature_NotPlayer_ReturnsCreatureColor()
{
// NPC: itemType has Creature bit, no Player flag.
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: 0);
Assert.Equal(RadarBlipColors.Creature, result);
}
[Fact]
public void FriendlyPlayer_ReturnsDefaultColor()
{
// Friendly player: Creature itemType + Player flag, no PK bits.
var result = RadarBlipColors.For((uint)ItemType.Creature, pwdBitfield: BF_PLAYER);
Assert.Equal(RadarBlipColors.Default, result);
}
[Fact]
public void PK_Player_ReturnsPlayerKillerColor()
{
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_PLAYER | BF_PLAYER_KILLER);
Assert.Equal(RadarBlipColors.PlayerKiller, result);
}
[Fact]
public void PKLite_Player_ReturnsPKLiteColor()
{
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_PLAYER | BF_PKLITE_PKSTATUS);
Assert.Equal(RadarBlipColors.PKLite, result);
}
[Fact]
public void Vendor_BeatsCreatureFlag()
{
// A vendor NPC: Creature itemType + Vendor flag. Vendor wins per
// retail's dispatch order (vendor check happens before creature
// check at 0x004d7946-004d7973).
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_VENDOR);
Assert.Equal(RadarBlipColors.Vendor, result);
}
[Fact]
public void Portal_TopPriority()
{
// Portal flag wins over everything else (retail dispatch order
// checks BF_PORTAL first).
var result = RadarBlipColors.For((uint)ItemType.Creature,
pwdBitfield: BF_PORTAL | BF_PLAYER | BF_PLAYER_KILLER);
Assert.Equal(RadarBlipColors.Portal, result);
}
}