diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs
index 1e752102..2e7b8b5d 100644
--- a/src/AcDream.Core/Combat/CombatModel.cs
+++ b/src/AcDream.Core/Combat/CombatModel.cs
@@ -204,6 +204,18 @@ public enum AttackType : uint
| OffhandDoubleThrust | OffhandTripleThrust,
}
+///
+/// Verbatim retail DAMAGE_TYPE (docs/research/named-retail/acclient.h:3788).
+///
+/// The four drain/restore bits used to be rotated here: Nether/Mana/Health/Stamina
+/// were assigned 0x80/0x100/0x200/0x400 where retail assigns Health/Stamina/Mana/Nether.
+/// Retail, the vendored client-side enum catalog, and the ACE weenie corpus all agree on
+/// retail's order (the corpus attests 0x100=Stamina, 0x200=Mana, 0x400=Nether directly),
+/// and both of acdream's live damage-type name tables — CombatChatTranslator and
+/// ItemAppraisalTextFormatter — were already reading the retail order off the raw
+/// wire uint. Nothing consumed the rotated members, so this corrects a latent trap rather
+/// than a live mislabel.
+///
[Flags]
public enum DamageType : uint
{
@@ -215,10 +227,11 @@ public enum DamageType : uint
Fire = 0x0010,
Acid = 0x0020,
Electric = 0x0040,
- Nether = 0x0080,
- Mana = 0x0100,
- Health = 0x0200,
- Stamina = 0x0400,
+ Health = 0x0080,
+ Stamina = 0x0100,
+ Mana = 0x0200,
+ Nether = 0x0400,
+ Base = 0x10000000,
}
///
diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs
index 8cb5f0d4..fc8b5a6d 100644
--- a/src/AcDream.Core/Items/ClientObject.cs
+++ b/src/AcDream.Core/Items/ClientObject.cs
@@ -10,9 +10,17 @@ namespace AcDream.Core.Items;
///
/// AC's ItemType is a 32-bit flags enum — a single dat weenie can
-/// assert multiple type bits. From ACE.Entity.Enum.ItemType
-/// cross-checked against the decompile paperdoll tooltip dispatcher.
-/// Full bit list in the research doc §1.
+/// assert multiple type bits. Verbatim retail ITEM_TYPE
+/// (docs/research/named-retail/acclient.h:3300).
+///
+/// The craft ladder used to be shifted: CraftAlchemyIntermediate sat on
+/// 0x02000000 (retail leaves that bit unused and puts alchemy-intermediate on
+/// 0x04000000), and an invented CraftCookingIntermediate occupied the real
+/// alchemy-intermediate bit. The ACE weenie corpus attests 0x04000000 =
+/// Craft_Alchemy_Intermediate 235 times and contains no cooking-intermediate at all.
+/// The composite masks were likewise recomputed locally rather than transcribed, which
+/// made Weapon an alias of WeaponOrCaster and left Item two orders
+/// of magnitude narrower than retail's. Full bit list in the research doc §1.
///
[Flags]
public enum ItemType : uint
@@ -43,17 +51,27 @@ public enum ItemType : uint
CraftCookingBase = 0x00400000,
CraftAlchemyBase = 0x00800000,
CraftFletchingBase = 0x01000000,
- CraftAlchemyIntermediate= 0x02000000,
- CraftCookingIntermediate= 0x04000000,
+ // 0x02000000 is deliberately unused in retail's ladder.
+ CraftAlchemyIntermediate= 0x04000000,
CraftFletchingIntermediate = 0x08000000,
LifeStone = 0x10000000,
TinkeringTool = 0x20000000,
TinkeringMaterial = 0x40000000,
Gameboard = 0x80000000u,
- Vestements = Armor | Clothing,
- Weapon = MeleeWeapon | MissileWeapon | Caster,
- WeaponOrCaster = Weapon,
- Item = Weapon | Armor | Clothing | Jewelry | Container,
+
+ // Composite masks, transcribed verbatim rather than recomputed from the bits
+ // above — retail's TYPE_ITEM in particular is far broader than any obvious
+ // union, and deriving these locally is how the craft ladder drifted.
+ Vestements = 0x00000006, // TYPE_VESTEMENTS
+ Weapon = 0x00000101, // TYPE_WEAPON: melee | missile, no caster
+ WeaponOrCaster = 0x00008101, // TYPE_WEAPON_OR_CASTER
+ LockableMagicTarget = 0x00000280, // TYPE_LOCKABLE_MAGIC_TARGET
+ RedirectableItemEnchantmentTarget = 0x00008107,
+ PortalMagicTarget = 0x10010000, // TYPE_PORTAL_MAGIC_TARGET
+ ItemEnchantableTarget = 0x00088B8F, // TYPE_ITEM_ENCHANTABLE_TARGET
+ Item = 0x002DFBEF, // TYPE_ITEM
+ VendorShopkeep = 0x480467A7, // TYPE_VENDOR_SHOPKEEP
+ VendorGrocer = 0x00446220, // TYPE_VENDOR_GROCER
}
///
diff --git a/tests/AcDream.Core.Tests/Properties/RetailEnumConformanceTests.cs b/tests/AcDream.Core.Tests/Properties/RetailEnumConformanceTests.cs
new file mode 100644
index 00000000..f5641e9d
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Properties/RetailEnumConformanceTests.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Linq;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using Xunit;
+
+namespace AcDream.Core.Tests.Properties;
+
+///
+/// Pins the wire-adjacent enums acdream shares with the retail client to the values
+/// in the Sept 2013 EoR header, docs/research/named-retail/acclient.h. Each
+/// table below is transcribed from the cited enum block, so a hand edit that
+/// drifts from retail fails here.
+///
+/// These tables are the reason the 2026-07-29 enum campaign found real bugs:
+/// had its four drain/restore bits rotated, and
+/// had a shifted craft ladder plus locally recomputed composite
+/// masks. Both are corrected and pinned below.
+///
+public sealed class RetailEnumConformanceTests
+{
+ /// acclient.h:3788, enum DAMAGE_TYPE. FORCE_*_32_BIT omitted.
+ public static TheoryData RetailDamageType => new()
+ {
+ { "Undef", 0x0 },
+ { "Slash", 0x1 },
+ { "Pierce", 0x2 },
+ { "Bludgeon", 0x4 },
+ { "Cold", 0x8 },
+ { "Fire", 0x10 },
+ { "Acid", 0x20 },
+ { "Electric", 0x40 },
+ { "Health", 0x80 },
+ { "Stamina", 0x100 },
+ { "Mana", 0x200 },
+ { "Nether", 0x400 },
+ { "Base", 0x10000000 },
+ };
+
+ [Theory]
+ [MemberData(nameof(RetailDamageType))]
+ public void DamageTypeMatchesRetail(string name, uint value)
+ {
+ Assert.True(Enum.IsDefined(typeof(DamageType), name),
+ $"DamageType.{name} is missing");
+ Assert.Equal(value, (uint)Enum.Parse(name));
+ }
+
+ [Fact]
+ public void DamageTypeDeclaresNothingRetailDoesNot()
+ {
+ var expected = RetailDamageType.Select(r => (string)r[0]).OrderBy(n => n, StringComparer.Ordinal);
+ var actual = Enum.GetNames().OrderBy(n => n, StringComparer.Ordinal);
+ Assert.Equal(expected, actual);
+ }
+
+ ///
+ /// acclient.h:3300, enum ITEM_TYPE. TYPE_UNDEF and TYPE_SELF both sit on 0
+ /// in retail; acdream spells that single zero None. FORCE_*_32_BIT omitted.
+ ///
+ public static TheoryData RetailItemType => new()
+ {
+ { "None", 0x0 },
+ { "MeleeWeapon", 0x1 },
+ { "Armor", 0x2 },
+ { "Vestements", 0x6 },
+ { "Clothing", 0x4 },
+ { "Jewelry", 0x8 },
+ { "Creature", 0x10 },
+ { "Food", 0x20 },
+ { "Money", 0x40 },
+ { "Misc", 0x80 },
+ { "MissileWeapon", 0x100 },
+ { "Weapon", 0x101 },
+ { "Container", 0x200 },
+ { "LockableMagicTarget", 0x280 },
+ { "Useless", 0x400 },
+ { "Gem", 0x800 },
+ { "SpellComponents", 0x1000 },
+ { "Writable", 0x2000 },
+ { "Key", 0x4000 },
+ { "Caster", 0x8000 },
+ { "WeaponOrCaster", 0x8101 },
+ { "RedirectableItemEnchantmentTarget", 0x8107 },
+ { "Portal", 0x10000 },
+ { "Lockable", 0x20000 },
+ { "PromissoryNote", 0x40000 },
+ { "ManaStone", 0x80000 },
+ { "ItemEnchantableTarget", 0x88B8F },
+ { "Service", 0x100000 },
+ { "MagicWieldable", 0x200000 },
+ { "Item", 0x2DFBEF },
+ { "CraftCookingBase", 0x400000 },
+ { "VendorGrocer", 0x446220 },
+ { "CraftAlchemyBase", 0x800000 },
+ { "CraftFletchingBase", 0x1000000 },
+ { "CraftAlchemyIntermediate", 0x4000000 },
+ { "CraftFletchingIntermediate", 0x8000000 },
+ { "LifeStone", 0x10000000 },
+ { "PortalMagicTarget", 0x10010000 },
+ { "TinkeringTool", 0x20000000 },
+ { "TinkeringMaterial", 0x40000000 },
+ { "VendorShopkeep", 0x480467A7 },
+ { "Gameboard", 0x80000000 },
+ };
+
+ [Theory]
+ [MemberData(nameof(RetailItemType))]
+ public void ItemTypeMatchesRetail(string name, uint value)
+ {
+ Assert.True(Enum.IsDefined(typeof(ItemType), name),
+ $"ItemType.{name} is missing");
+ Assert.Equal(value, (uint)Enum.Parse(name));
+ }
+
+ [Fact]
+ public void ItemTypeDeclaresNothingRetailDoesNot()
+ {
+ var expected = RetailItemType.Select(r => (string)r[0]).OrderBy(n => n, StringComparer.Ordinal);
+ var actual = Enum.GetNames().OrderBy(n => n, StringComparer.Ordinal);
+ Assert.Equal(expected, actual);
+ }
+
+ ///
+ /// The craft ladder specifically: retail leaves 0x02000000 unused, and the ACE
+ /// weenie corpus attests 0x04000000 as Craft_Alchemy_Intermediate 235 times. This
+ /// is the pairing acdream had wrong.
+ ///
+ [Fact]
+ public void CraftLadderMatchesRetailAndLeavesTheUnusedBitUnclaimed()
+ {
+ Assert.Equal(0x00400000u, (uint)ItemType.CraftCookingBase);
+ Assert.Equal(0x00800000u, (uint)ItemType.CraftAlchemyBase);
+ Assert.Equal(0x01000000u, (uint)ItemType.CraftFletchingBase);
+ Assert.Equal(0x04000000u, (uint)ItemType.CraftAlchemyIntermediate);
+ Assert.Equal(0x08000000u, (uint)ItemType.CraftFletchingIntermediate);
+ Assert.DoesNotContain(Enum.GetValues(), t => (uint)t == 0x02000000u);
+ }
+
+ ///
+ /// Weapon and WeaponOrCaster were aliases before the campaign, because
+ /// Weapon was recomputed locally as melee|missile|caster. Retail keeps them
+ /// distinct and only the latter includes the caster bit.
+ ///
+ [Fact]
+ public void WeaponExcludesCasterAndWeaponOrCasterIncludesIt()
+ {
+ Assert.NotEqual(ItemType.Weapon, ItemType.WeaponOrCaster);
+ Assert.Equal(ItemType.MeleeWeapon | ItemType.MissileWeapon, ItemType.Weapon);
+ Assert.Equal(ItemType.Weapon | ItemType.Caster, ItemType.WeaponOrCaster);
+ }
+}