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 <noreply@anthropic.com>
This commit is contained in:
parent
8ccaf72ae7
commit
3efa266a61
3 changed files with 250 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
104
src/AcDream.Core/Items/ItemWireEnums.cs
Normal file
104
src/AcDream.Core/Items/ItemWireEnums.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim retail <c>AMMO_TYPE</c> (<c>docs/research/named-retail/acclient.h:4221</c>).
|
||||
/// Arrives on the wire as <c>PublicWeenieDesc._ammo_type</c> (CreateObject weenie flag
|
||||
/// 0x00000100) and in the appraisal weapon profile.
|
||||
///
|
||||
/// <para>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
|
||||
/// <c>ItemAppraisalTextFormatter</c>, which does exactly that.</para>
|
||||
/// </summary>
|
||||
[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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim retail <c>COMBAT_USE</c> (<c>docs/research/named-retail/acclient.h:6523</c>) —
|
||||
/// how a wieldable participates in combat. This is a plain sequence, not a bitfield.
|
||||
/// Arrives as <c>PropertyInt.CombatUse</c> (51).
|
||||
/// </summary>
|
||||
public enum CombatUse : uint
|
||||
{
|
||||
None = 0,
|
||||
Melee = 1,
|
||||
Missile = 2,
|
||||
Ammo = 3,
|
||||
Shield = 4,
|
||||
TwoHanded = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim retail <c>ITEM_USEABLE</c> (<c>docs/research/named-retail/acclient.h:6478</c>) —
|
||||
/// where an object must be, and where its target must be, for a use attempt to be legal.
|
||||
/// Arrives as <c>PropertyInt.ItemUseable</c> (16).
|
||||
///
|
||||
/// <para>The layout is two 16-bit halves: the low half (<see cref="SourceMask"/>) constrains
|
||||
/// the object being used, the high half (<see cref="TargetMask"/>) 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
|
||||
/// (<see cref="SourceContainedTargetObjSelfOrContained"/> is 0x880008, not
|
||||
/// <c>ObjSelf | Contained | (Contained << 16)</c>).</para>
|
||||
/// </summary>
|
||||
[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,
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Properties;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>docs/research/named-retail/acclient.h</c>.
|
||||
/// </summary>
|
||||
public sealed class ItemWireEnumConformanceTests
|
||||
{
|
||||
/// <summary>acclient.h:4221, <c>enum AMMO_TYPE</c>.</summary>
|
||||
public static TheoryData<string, uint> 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<AmmoType>(name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmmoTypeDeclaresNothingRetailDoesNot()
|
||||
{
|
||||
var expected = RetailAmmoType.Select(r => (string)r[0]).OrderBy(n => n, StringComparer.Ordinal);
|
||||
Assert.Equal(expected, Enum.GetNames<AmmoType>().OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>acclient.h:6523, <c>enum COMBAT_USE</c>.</summary>
|
||||
[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<CombatUse>(name));
|
||||
}
|
||||
|
||||
/// <summary>acclient.h:6478, <c>enum ITEM_USEABLE</c>, including the composites.</summary>
|
||||
public static TheoryData<string, uint> 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<ItemUseable>(name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemUseableDeclaresNothingRetailDoesNot()
|
||||
{
|
||||
var expected = RetailItemUseable.Select(r => (string)r[0]).OrderBy(n => n, StringComparer.Ordinal);
|
||||
Assert.Equal(expected, Enum.GetNames<ItemUseable>().OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue