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);
+ }
+}