From 3efa266a6120d29fa4d8902514d85ae296b09ce8 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 01:31:01 +0200 Subject: [PATCH] feat(core): name AmmoType, CombatUse, and ItemUseable Three more fields acdream already pulls off the wire and then carries as bare numbers. AmmoType and MaterialType ride PublicWeenieDesc through CreateObject and land on ClientObject as ushort/uint; ItemUseable and CombatUse arrive as PropertyInt 16 and 51. Nothing named them, so every site that reasoned about them did it in hex. AmmoType (acclient.h:4221) and CombatUse (acclient.h:6523) are small and unsurprising. ItemUseable (acclient.h:6478) is neither: it is two 16-bit halves, low for where the used object must be and high for where its target must be, and retail names roughly thirty specific combinations rather than expecting callers to compose them. They are transcribed rather than composed because at least one is not the union it looks like - SOURCE_CONTAINED_TARGET_OBJSELF_OR_CONTAINED is 0x880008, where composing ObjSelf|Contained|(Contained shifted 16) gives 0x800088. A test asserts that specific non-equality so the shortcut cannot be reintroduced. ItemAppraisalTextFormatter's ammunition sentence now reads through AmmoType instead of matching 0x08/0x40/0x10/0x80/0x20/0x100 literals. The fold it performs - crystal and chorizite variants collapsing to their base arrow/bolt/atlatl kind - was already exactly right against retail's bit layout; this only gives it vocabulary. No behavior change, and the appraisal tests confirm it. Core tests 3,836 -> 3,894. Full suite 9,759 passed / 5 skipped, no failures. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/ItemAppraisalTextFormatter.cs | 24 ++-- src/AcDream.Core/Items/ItemWireEnums.cs | 104 ++++++++++++++ .../ItemWireEnumConformanceTests.cs | 133 ++++++++++++++++++ 3 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 src/AcDream.Core/Items/ItemWireEnums.cs create mode 100644 tests/AcDream.Core.Tests/Properties/ItemWireEnumConformanceTests.cs 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); + } +}