fix(ui): port exact selected health policy

Replace the toolbar's PWD-bit approximation with retail ObjectIsAttackable composed with the exact player and pet short-circuits. Carry second-header PetOwner through CreateObject, session, and ClientObject state so self, pet, and Free-PK cases match retail while friendly NPCs and attackable non-creatures remain name-only.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 11:42:04 +02:00
parent 52a80dc531
commit 2644d1d527
18 changed files with 416 additions and 50 deletions

View file

@ -566,6 +566,29 @@ public sealed class CreateObjectTests
Assert.Equal("Pyreal Scarabs", parsed.Value.PluralName);
}
[Fact]
public void WeenieHeader_secondHeaderPetOwner_isPreservedAfterPrecedingFields()
{
// PublicWeenieDesc::UnPack @ 0x005AD7AC..0x005AD7F8:
// MaterialType, CooldownId, CooldownDuration, then PetOwner.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000024u,
name: "Combat Pet",
itemType: (uint)ItemType.Creature,
objectDescriptionFlags: 0x04000000u,
weenieFlags: 0x80000000u,
weenieFlags2: 0x0000000Eu,
materialType: 7u,
cooldownId: 11u,
cooldownDuration: 12.5,
petOwnerId: 0x50000001u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x50000001u, parsed.Value.PetOwnerId);
}
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid,
string name,
@ -601,7 +624,11 @@ public sealed class CreateObjectTests
byte? radarBlipColor = null,
byte? radarBehavior = null,
byte? combatUse = null,
ushort movementSeq = 0)
ushort movementSeq = 0,
uint materialType = 0,
uint cooldownId = 0,
double cooldownDuration = 0,
uint petOwnerId = 0)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
@ -691,6 +718,16 @@ public sealed class CreateObjectTests
if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, 0); // HookType u16
if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay
if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay
if ((weenieFlags & 0x80000000u) != 0) WriteU32(bytes, materialType); // MaterialType
if ((weenieFlags2 & 0x00000002u) != 0) WriteU32(bytes, cooldownId); // CooldownId
if ((weenieFlags2 & 0x00000004u) != 0)
{
Span<byte> tmp = stackalloc byte[8];
BinaryPrimitives.WriteDoubleLittleEndian(tmp, cooldownDuration);
bytes.AddRange(tmp.ToArray());
}
if ((weenieFlags2 & 0x00000008u) != 0) WriteU32(bytes, petOwnerId); // PetOwner
Align4(bytes);
return bytes.ToArray();
}

View file

@ -64,6 +64,7 @@ public sealed class ObjectTableWiringTests
ObjectDescriptionFlags = 0x20800200u,
CombatUse = 2,
PluralName = "Iron Swords",
PetOwnerId = 0x50000001u,
};
var d = ObjectTableWiring.ToWeenieData(spawn);
@ -110,6 +111,7 @@ public sealed class ObjectTableWiringTests
Assert.Equal(0x20800200u, d.PublicWeenieBitfield);
Assert.Equal((byte)2, d.CombatUse);
Assert.Equal("Iron Swords", d.PluralName);
Assert.Equal(0x50000001u, d.PetOwnerId);
}
// -------------------------------------------------------------------------

View file

@ -39,6 +39,17 @@ public sealed class WorldSessionRadarTests
Assert.Equal("Pyreal Scarabs", spawn.PluralName);
}
[Fact]
public void ToEntitySpawn_PreservesPetOwner()
{
var spawn = WorldSession.ToEntitySpawn(MinimalParsed() with
{
PetOwnerId = 0x50000002u,
});
Assert.Equal(0x50000002u, spawn.PetOwnerId);
}
private static CreateObject.Parsed MinimalParsed() => new(
Guid: 0x50000001u,
Position: null,

View file

@ -0,0 +1,96 @@
using AcDream.Core.Combat;
using AcDream.Core.Items;
using Xunit;
namespace AcDream.Core.Tests.Combat;
public sealed class SelectedObjectHealthPolicyTests
{
private const uint PlayerId = 0x50000001u;
private static ClientObject Obj(
uint id,
ItemType type = ItemType.Creature,
uint flags = 0,
uint petOwner = 0) => new()
{
ObjectId = id,
Type = type,
PublicWeenieBitfield = flags,
PetOwnerId = petOwner,
};
[Fact]
public void Toolbar_PlayerAndPetShortCircuitAttackability()
{
var local = Obj(PlayerId, flags: SelectedObjectHealthPolicy.BfPlayer);
var otherPlayer = Obj(0x50000002u, flags: SelectedObjectHealthPolicy.BfPlayer);
var pet = Obj(0x50000003u, petOwner: PlayerId);
Assert.True(SelectedObjectHealthPolicy.ShouldQueryHealth(PlayerId, local, otherPlayer));
Assert.True(SelectedObjectHealthPolicy.ShouldQueryHealth(PlayerId, local, pet));
}
[Fact]
public void Toolbar_SelfSelectionGetsHealthQuery()
{
var local = Obj(PlayerId, flags: SelectedObjectHealthPolicy.BfPlayer);
Assert.True(SelectedObjectHealthPolicy.ShouldQueryHealth(PlayerId, local, local));
}
[Fact]
public void Toolbar_AttackableCreatureTrue_FriendlyNpcAndAttackableDoorFalse()
{
var local = Obj(PlayerId, flags: SelectedObjectHealthPolicy.BfPlayer);
var monster = Obj(0x50000010u, flags: SelectedObjectHealthPolicy.BfAttackable);
var friendlyNpc = Obj(0x50000011u);
var door = Obj(0x50000012u, ItemType.Misc, SelectedObjectHealthPolicy.BfAttackable);
Assert.True(SelectedObjectHealthPolicy.ShouldQueryHealth(PlayerId, local, monster));
Assert.False(SelectedObjectHealthPolicy.ShouldQueryHealth(PlayerId, local, friendlyNpc));
Assert.False(SelectedObjectHealthPolicy.ShouldQueryHealth(PlayerId, local, door));
}
[Fact]
public void ObjectIsAttackable_FreePkOnEitherCreatureSideReturnsTrue()
{
var normalPlayer = Obj(PlayerId, flags: SelectedObjectHealthPolicy.BfPlayer);
var freePlayer = Obj(PlayerId,
flags: SelectedObjectHealthPolicy.BfPlayer | SelectedObjectHealthPolicy.BfFreePkStatus);
var normalCreature = Obj(0x50000020u);
var freeCreature = Obj(0x50000021u, flags: SelectedObjectHealthPolicy.BfFreePkStatus);
Assert.True(SelectedObjectHealthPolicy.ObjectIsAttackable(
PlayerId, normalPlayer, freeCreature.ObjectId, freeCreature));
Assert.True(SelectedObjectHealthPolicy.ObjectIsAttackable(
PlayerId, freePlayer, normalCreature.ObjectId, normalCreature));
}
[Fact]
public void ObjectIsAttackable_PlayerRequiresMatchingPkPool()
{
var pkPlayer = Obj(PlayerId,
flags: SelectedObjectHealthPolicy.BfPlayer | SelectedObjectHealthPolicy.BfPlayerKiller);
var pkTarget = Obj(0x50000030u,
flags: SelectedObjectHealthPolicy.BfPlayer | SelectedObjectHealthPolicy.BfPlayerKiller);
var pkLiteTarget = Obj(0x50000031u,
flags: SelectedObjectHealthPolicy.BfPlayer | SelectedObjectHealthPolicy.BfPkLiteStatus);
Assert.True(SelectedObjectHealthPolicy.ObjectIsAttackable(
PlayerId, pkPlayer, pkTarget.ObjectId, pkTarget));
Assert.False(SelectedObjectHealthPolicy.ObjectIsAttackable(
PlayerId, pkPlayer, pkLiteTarget.ObjectId, pkLiteTarget));
}
[Fact]
public void ObjectIsAttackable_ZeroOrSelfTrue_MissingPlayerBlocksNormalCreature()
{
var creature = Obj(0x50000040u, flags: SelectedObjectHealthPolicy.BfAttackable);
Assert.True(SelectedObjectHealthPolicy.ObjectIsAttackable(PlayerId, null, 0u, null));
Assert.True(SelectedObjectHealthPolicy.ObjectIsAttackable(PlayerId, null, PlayerId, null));
Assert.False(SelectedObjectHealthPolicy.ObjectIsAttackable(
PlayerId, null, creature.ObjectId, creature));
}
}

View file

@ -204,6 +204,25 @@ public sealed class ClientObjectTableTests
Assert.Equal(0x99u, d.ContainerId);
}
[Fact]
public void Ingest_PetOwnerFromSecondHeader_IsPreserved()
{
var table = new ClientObjectTable();
var data = new WeenieData(
Guid: 0x50000010u, Name: "Combat Pet", Type: ItemType.Creature,
WeenieClassId: 2, IconId: 0, IconOverlayId: 0, IconUnderlayId: 0, Effects: 0,
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,
PetOwnerId: 0x50000001u);
ClientObject result = table.Ingest(data);
Assert.Equal(0x50000001u, result.PetOwnerId);
}
private static WeenieData FullWeenie(uint guid, uint icon = 0x06001234u,
string name = "Sword", ItemType type = ItemType.MeleeWeapon, uint effects = 0,
int? value = 100, int? stack = 1, uint? container = null, uint wcid = 0xABCDu) =>