diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs index 8aaa60fc..82400411 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -398,22 +398,24 @@ public static class ItemAppraisalTextFormatter if (ammoType == 0u) return; - uint baseAmmoType = ammoType switch + // Retail gives the crystal and chorizite variants their own bits + // (acclient.h:4221); each folds back to the base kind for this sentence. + AmmoType baseAmmoType = (AmmoType)ammoType switch { - 0x08u or 0x40u => 1u, - 0x10u or 0x80u => 2u, - 0x20u or 0x100u => 4u, - _ => ammoType, + AmmoType.ArrowCrystal or AmmoType.ArrowChorizite => AmmoType.Arrow, + AmmoType.BoltCrystal or AmmoType.BoltChorizite => AmmoType.Bolt, + AmmoType.AtlatlCrystal or AmmoType.AtlatlChorizite => AmmoType.Atlatl, + var other => other, }; bool launcher = (validLocations & (uint)EquipMask.MissileWeapon) != 0; string? description = (launcher, baseAmmoType) switch { - (true, 1u) => "Uses arrows as ammunition.", - (true, 2u) => "Uses quarrels as ammunition.", - (true, 4u) => "Uses atlatl darts as ammunition.", - (false, 1u) => "Used as ammunition by bows.", - (false, 2u) => "Used as ammunition by crossbows.", - (false, 4u) => "Used as ammunition by atlatls.", + (true, AmmoType.Arrow) => "Uses arrows as ammunition.", + (true, AmmoType.Bolt) => "Uses quarrels as ammunition.", + (true, AmmoType.Atlatl) => "Uses atlatl darts as ammunition.", + (false, AmmoType.Arrow) => "Used as ammunition by bows.", + (false, AmmoType.Bolt) => "Used as ammunition by crossbows.", + (false, AmmoType.Atlatl) => "Used as ammunition by atlatls.", _ => null, }; if (description is not null) diff --git a/src/AcDream.Core/Items/ItemWireEnums.cs b/src/AcDream.Core/Items/ItemWireEnums.cs new file mode 100644 index 00000000..af0df8a3 --- /dev/null +++ b/src/AcDream.Core/Items/ItemWireEnums.cs @@ -0,0 +1,104 @@ +using System; + +namespace AcDream.Core.Items; + +/// +/// Verbatim retail AMMO_TYPE (docs/research/named-retail/acclient.h:4221). +/// Arrives on the wire as PublicWeenieDesc._ammo_type (CreateObject weenie flag +/// 0x00000100) and in the appraisal weapon profile. +/// +/// The three base kinds each have a crystal and a chorizite variant on their own +/// bit, so a launcher's "uses arrows" test must fold the variants back to the base — see +/// ItemAppraisalTextFormatter, which does exactly that. +/// +[Flags] +public enum AmmoType : uint +{ + None = 0x000, + Arrow = 0x001, + Bolt = 0x002, + Atlatl = 0x004, + ArrowCrystal = 0x008, + BoltCrystal = 0x010, + AtlatlCrystal = 0x020, + ArrowChorizite = 0x040, + BoltChorizite = 0x080, + AtlatlChorizite = 0x100, +} + +/// +/// Verbatim retail COMBAT_USE (docs/research/named-retail/acclient.h:6523) — +/// how a wieldable participates in combat. This is a plain sequence, not a bitfield. +/// Arrives as PropertyInt.CombatUse (51). +/// +public enum CombatUse : uint +{ + None = 0, + Melee = 1, + Missile = 2, + Ammo = 3, + Shield = 4, + TwoHanded = 5, +} + +/// +/// Verbatim retail ITEM_USEABLE (docs/research/named-retail/acclient.h:6478) — +/// where an object must be, and where its target must be, for a use attempt to be legal. +/// Arrives as PropertyInt.ItemUseable (16). +/// +/// The layout is two 16-bit halves: the low half () constrains +/// the object being used, the high half () constrains its target. +/// Retail names the useful combinations explicitly rather than expecting callers to compose +/// them, and they are transcribed here the same way — several are not the obvious union +/// ( is 0x880008, not +/// ObjSelf | Contained | (Contained << 16)). +/// +[Flags] +public enum ItemUseable : uint +{ + Undef = 0x0, + No = 0x1, + Self = 0x2, + Wielded = 0x4, + Contained = 0x8, + Viewed = 0x10, + Remote = 0x20, + NeverWalk = 0x40, + ObjSelf = 0x80, + + ContainedViewed = 0x18, + ViewedRemote = 0x30, + ContainedViewedRemote = 0x38, + RemoteNeverWalk = 0x60, + ViewedRemoteNeverWalk = 0x70, + ContainedViewedRemoteNeverWalk = 0x78, + + SourceWieldedTargetWielded = 0x00040004, + SourceWieldedTargetContained = 0x00080004, + SourceWieldedTargetViewed = 0x00100004, + SourceWieldedTargetRemote = 0x00200004, + SourceWieldedTargetRemoteNeverWalk = 0x00600004, + + SourceContainedTargetWielded = 0x00040008, + SourceContainedTargetContained = 0x00080008, + SourceContainedTargetSelfOrContained = 0x000A0008, + SourceContainedTargetViewed = 0x00100008, + SourceContainedTargetRemote = 0x00200008, + SourceContainedTargetRemoteOrSelf = 0x00220008, + SourceContainedTargetRemoteNeverWalk = 0x00600008, + SourceContainedTargetObjSelfOrContained = 0x00880008, + + SourceViewedTargetWielded = 0x00040010, + SourceViewedTargetContained = 0x00080010, + SourceViewedTargetViewed = 0x00100010, + SourceViewedTargetRemote = 0x00200010, + + SourceRemoteTargetWielded = 0x00040020, + SourceRemoteTargetContained = 0x00080020, + SourceRemoteTargetViewed = 0x00100020, + SourceRemoteTargetRemote = 0x00200020, + SourceRemoteTargetRemoteNeverWalk = 0x00600020, + + SourceMask = 0x0000FFFF, + TargetMask = 0xFFFF0000, +} diff --git a/tests/AcDream.Core.Tests/Properties/ItemWireEnumConformanceTests.cs b/tests/AcDream.Core.Tests/Properties/ItemWireEnumConformanceTests.cs new file mode 100644 index 00000000..8c4c1bad --- /dev/null +++ b/tests/AcDream.Core.Tests/Properties/ItemWireEnumConformanceTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Linq; +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.Core.Tests.Properties; + +/// +/// Pins the three item-data enums the 2026-07-29 campaign added — all of them describe +/// fields acdream already parses off the wire and stored as bare numbers. Tables are +/// transcribed from docs/research/named-retail/acclient.h. +/// +public sealed class ItemWireEnumConformanceTests +{ + /// acclient.h:4221, enum AMMO_TYPE. + public static TheoryData RetailAmmoType => new() + { + { "None", 0x0 }, + { "Arrow", 0x1 }, + { "Bolt", 0x2 }, + { "Atlatl", 0x4 }, + { "ArrowCrystal", 0x8 }, + { "BoltCrystal", 0x10 }, + { "AtlatlCrystal", 0x20 }, + { "ArrowChorizite", 0x40 }, + { "BoltChorizite", 0x80 }, + { "AtlatlChorizite", 0x100 }, + }; + + [Theory] + [MemberData(nameof(RetailAmmoType))] + public void AmmoTypeMatchesRetail(string name, uint value) + { + Assert.True(Enum.IsDefined(typeof(AmmoType), name), $"AmmoType.{name} is missing"); + Assert.Equal(value, (uint)Enum.Parse(name)); + } + + [Fact] + public void AmmoTypeDeclaresNothingRetailDoesNot() + { + var expected = RetailAmmoType.Select(r => (string)r[0]).OrderBy(n => n, StringComparer.Ordinal); + Assert.Equal(expected, Enum.GetNames().OrderBy(n => n, StringComparer.Ordinal)); + } + + /// acclient.h:6523, enum COMBAT_USE. + [Theory] + [InlineData("None", 0u)] + [InlineData("Melee", 1u)] + [InlineData("Missile", 2u)] + [InlineData("Ammo", 3u)] + [InlineData("Shield", 4u)] + [InlineData("TwoHanded", 5u)] + public void CombatUseMatchesRetail(string name, uint value) + { + Assert.True(Enum.IsDefined(typeof(CombatUse), name), $"CombatUse.{name} is missing"); + Assert.Equal(value, (uint)Enum.Parse(name)); + } + + /// acclient.h:6478, enum ITEM_USEABLE, including the composites. + public static TheoryData RetailItemUseable => new() + { + { "Undef", 0x0 }, + { "No", 0x1 }, + { "Self", 0x2 }, + { "Wielded", 0x4 }, + { "Contained", 0x8 }, + { "Viewed", 0x10 }, + { "ContainedViewed", 0x18 }, + { "Remote", 0x20 }, + { "ViewedRemote", 0x30 }, + { "ContainedViewedRemote", 0x38 }, + { "NeverWalk", 0x40 }, + { "RemoteNeverWalk", 0x60 }, + { "ViewedRemoteNeverWalk", 0x70 }, + { "ContainedViewedRemoteNeverWalk", 0x78 }, + { "ObjSelf", 0x80 }, + { "SourceMask", 0xFFFF }, + { "SourceWieldedTargetWielded", 0x40004 }, + { "SourceContainedTargetWielded", 0x40008 }, + { "SourceViewedTargetWielded", 0x40010 }, + { "SourceRemoteTargetWielded", 0x40020 }, + { "SourceWieldedTargetContained", 0x80004 }, + { "SourceContainedTargetContained", 0x80008 }, + { "SourceViewedTargetContained", 0x80010 }, + { "SourceRemoteTargetContained", 0x80020 }, + { "SourceContainedTargetSelfOrContained", 0xA0008 }, + { "SourceWieldedTargetViewed", 0x100004 }, + { "SourceContainedTargetViewed", 0x100008 }, + { "SourceViewedTargetViewed", 0x100010 }, + { "SourceRemoteTargetViewed", 0x100020 }, + { "SourceWieldedTargetRemote", 0x200004 }, + { "SourceContainedTargetRemote", 0x200008 }, + { "SourceViewedTargetRemote", 0x200010 }, + { "SourceRemoteTargetRemote", 0x200020 }, + { "SourceContainedTargetRemoteOrSelf", 0x220008 }, + { "SourceWieldedTargetRemoteNeverWalk", 0x600004 }, + { "SourceContainedTargetRemoteNeverWalk", 0x600008 }, + { "SourceRemoteTargetRemoteNeverWalk", 0x600020 }, + { "SourceContainedTargetObjSelfOrContained", 0x880008 }, + { "TargetMask", 0xFFFF0000 }, + }; + + [Theory] + [MemberData(nameof(RetailItemUseable))] + public void ItemUseableMatchesRetail(string name, uint value) + { + Assert.True(Enum.IsDefined(typeof(ItemUseable), name), $"ItemUseable.{name} is missing"); + Assert.Equal(value, (uint)Enum.Parse(name)); + } + + [Fact] + public void ItemUseableDeclaresNothingRetailDoesNot() + { + var expected = RetailItemUseable.Select(r => (string)r[0]).OrderBy(n => n, StringComparer.Ordinal); + Assert.Equal(expected, Enum.GetNames().OrderBy(n => n, StringComparer.Ordinal)); + } + + /// + /// The two halves partition the word, and at least one retail composite is + /// deliberately not the obvious union — which is why they are transcribed rather + /// than composed. + /// + [Fact] + public void ItemUseableSourceAndTargetMasksPartitionTheWord() + { + Assert.Equal(0u, (uint)ItemUseable.SourceMask & (uint)ItemUseable.TargetMask); + Assert.Equal(uint.MaxValue, (uint)ItemUseable.SourceMask | (uint)ItemUseable.TargetMask); + + uint naiveUnion = (uint)ItemUseable.ObjSelf | (uint)ItemUseable.Contained + | ((uint)ItemUseable.Contained << 16); + Assert.NotEqual(naiveUnion, (uint)ItemUseable.SourceContainedTargetObjSelfOrContained); + } +}