acdream/src/AcDream.Core/Items/ClientObject.cs
Erik 6d65177357 style(D.2b): EquipMask polish — align FingerWearRight, note bit 31 (code review)
Code-quality review minors on Task 2: fix the missing space in the
FingerWearRight= alignment and document why bit 31 (0x80000000, present
only in the header's CLOTHING_LOC composite) has no named member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:01:07 +02:00

292 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
namespace AcDream.Core.Items;
// ─────────────────────────────────────────────────────────────────────
// Scaffold for R6 — items + inventory data model.
// Full research: docs/research/deepdives/r06-items-inventory.md
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// AC's <c>ItemType</c> is a 32-bit flags enum — a single dat weenie can
/// assert multiple type bits. From <c>ACE.Entity.Enum.ItemType</c>
/// cross-checked against the decompile paperdoll tooltip dispatcher.
/// Full bit list in the research doc §1.
/// </summary>
[Flags]
public enum ItemType : uint
{
None = 0,
MeleeWeapon = 0x00000001,
Armor = 0x00000002,
Clothing = 0x00000004,
Jewelry = 0x00000008,
Creature = 0x00000010,
Food = 0x00000020,
Money = 0x00000040,
Misc = 0x00000080,
MissileWeapon = 0x00000100,
Container = 0x00000200,
Useless = 0x00000400,
Gem = 0x00000800,
SpellComponents = 0x00001000,
Writable = 0x00002000,
Key = 0x00004000,
Caster = 0x00008000,
Portal = 0x00010000,
Lockable = 0x00020000,
PromissoryNote = 0x00040000,
ManaStone = 0x00080000,
Service = 0x00100000,
MagicWieldable = 0x00200000,
CraftCookingBase = 0x00400000,
CraftAlchemyBase = 0x00800000,
CraftFletchingBase = 0x01000000,
CraftAlchemyIntermediate= 0x02000000,
CraftCookingIntermediate= 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,
}
/// <summary>
/// Equipment slot bitmask — the verbatim retail <c>INVENTORY_LOC</c> enum
/// (docs/research/named-retail/acclient.h:3193; identical to ACE's EquipMask).
/// The wire (ValidLocations / CurrentWieldedLocation / WieldObject EquipLoc) delivers
/// these exact bits. Pinned by EquipMaskTests — do NOT renumber.
/// (The header's <c>CLOTHING_LOC</c> composite also sets bit 31, 0x80000000, which is
/// not a named INVENTORY_LOC primitive and has no member here; <c>ALL_LOC</c> tops out at bit 30.)
/// </summary>
[Flags]
public enum EquipMask : uint
{
None = 0x00000000,
HeadWear = 0x00000001,
ChestWear = 0x00000002,
AbdomenWear = 0x00000004,
UpperArmWear = 0x00000008,
LowerArmWear = 0x00000010,
HandWear = 0x00000020,
UpperLegWear = 0x00000040,
LowerLegWear = 0x00000080,
FootWear = 0x00000100,
ChestArmor = 0x00000200,
AbdomenArmor = 0x00000400,
UpperArmArmor = 0x00000800,
LowerArmArmor = 0x00001000,
UpperLegArmor = 0x00002000,
LowerLegArmor = 0x00004000,
NeckWear = 0x00008000,
WristWearLeft = 0x00010000,
WristWearRight = 0x00020000,
FingerWearLeft = 0x00040000,
FingerWearRight = 0x00080000,
MeleeWeapon = 0x00100000,
Shield = 0x00200000,
MissileWeapon = 0x00400000,
MissileAmmo = 0x00800000,
Held = 0x01000000,
TwoHanded = 0x02000000,
TrinketOne = 0x04000000,
Cloak = 0x08000000,
SigilOne = 0x10000000,
SigilTwo = 0x20000000,
SigilThree = 0x40000000,
}
/// <summary>
/// AC's property model is split across 7 typed tables. See r06 §3 for
/// the full property enumeration. This struct is a thin wrapper; real
/// bundles come over the wire in <c>ObjectDesc</c> / <c>IdentifyResponse</c>.
/// </summary>
public sealed class PropertyBundle
{
public Dictionary<uint, int> Ints { get; } = new();
public Dictionary<uint, long> Int64s { get; } = new();
public Dictionary<uint, bool> Bools { get; } = new();
public Dictionary<uint, double> Floats { get; } = new();
public Dictionary<uint, string> Strings { get; } = new();
public Dictionary<uint, uint> DataIds { get; } = new();
public Dictionary<uint, uint> InstanceIds { get; } = new();
public int GetInt (uint k, int def = 0) => Ints.TryGetValue(k, out var v) ? v : def;
public bool GetBool (uint k, bool def = false) => Bools.TryGetValue(k, out var v) ? v : def;
public double GetFloat (uint k, double def = 0) => Floats.TryGetValue(k, out var v) ? v : def;
public string GetString(uint k, string def = "") => Strings.TryGetValue(k, out var v) ? v : def;
}
/// <summary>
/// Per-object live state (the data side of every server object — items and creatures alike).
/// Retail <c>ACCWeenieObject</c>.
/// </summary>
public sealed class ClientObject
{
public uint ObjectId { get; init; }
public uint WeenieClassId { get; set; } // "blueprint"
public string Name { get; set; } = "";
public ItemType Type { get; set; }
public EquipMask ValidLocations { get; set; }
public EquipMask CurrentlyEquippedLocation { get; set; }
public uint IconId { get; set; } // 0x06xxxxxx
public uint IconUnderlayId{ get; set; } // "magic" underlay
public uint IconOverlayId { get; set; } // "enchanted" overlay
/// <summary>
/// UiEffects bitfield (retail PublicWeenieDesc._effects, acclient.h:37183).
/// Drives the icon's effect-overlay recolor (Magical=0x1 … Nether=0x1000).
/// CreateObject-only (weenieFlags 0x80) + live PublicUpdatePropertyInt(0x02CE);
/// appraise never carries it. 0 = no effect.
/// </summary>
public uint Effects { get; set; }
public int StackSize { get; set; } = 1;
public int StackSizeMax { get; set; } = 1;
public int Burden { get; set; } // per-stack total
public int Value { get; set; } // pyreals
public uint ContainerId { get; set; } // parent container ObjectId, or 0
public int ContainerSlot { get; set; } = -1;
public bool Attuned { get; set; }
public bool Bonded { get; set; }
public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded
public int ItemsCapacity { get; set; } // main-pack slots (containers)
public int ContainersCapacity{ get; set; } // side-pack slots (containers)
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
public int Structure { get; set; } // charges/uses remaining
public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
public PropertyBundle Properties { get; } = new();
}
/// <summary>
/// The wire-delivered patch from a <c>CreateObject</c> (0xF745). Nullable fields
/// were gated by a WeenieHeader flag that was ABSENT — the merge upsert
/// (ClientObjectTable.Ingest) leaves the existing value untouched
/// for those, matching retail's <c>SetWeenieDesc</c> (patches only present fields).
/// Non-nullable id/effect fields use 0 = "not sent". Effects is assigned
/// unconditionally (0 clears) — the D.5.2 icon contract. Quantity fields are
/// int? (ACE PropertyInt convention); id/mask fields are uint?.
/// </summary>
public readonly record struct WeenieData(
uint Guid,
string? Name,
ItemType? Type,
uint WeenieClassId,
uint IconId,
uint IconOverlayId,
uint IconUnderlayId,
uint Effects,
int? Value,
int? StackSize,
int? StackSizeMax,
int? Burden,
uint? ContainerId,
uint? WielderId,
uint? ValidLocations,
uint? CurrentWieldedLocation,
uint? Priority,
int? ItemsCapacity,
int? ContainersCapacity,
int? Structure,
int? MaxStructure,
float? Workmanship);
/// <summary>
/// Container = inventory pack. Hierarchy is strictly 2-deep: character
/// → side packs; a side pack cannot hold another side pack (r06 §7).
/// </summary>
public sealed class Container
{
public uint ObjectId { get; init; }
public int Capacity { get; set; } = 102; // main inv default
public int SideCapacity { get; set; } = 0; // 0 for side-pack
public int BurdenLimit { get; set; }
public List<ClientObject> Items { get; } = new();
public List<Container> SidePacks { get; } = new(); // empty for side-pack
public bool IsSidePack => SideCapacity == 0;
}
/// <summary>
/// Burden math — r06 §6. <c>maxBurden = 150 × Strength + Strength × bonusBurden</c>;
/// carry limit is <c>3 × maxBurden</c> before you can't pick up at all.
/// </summary>
public static class BurdenMath
{
public const int BurdenPerStrength = 150;
/// <summary>Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e).</summary>
public const int AugBurdenPerRank = 30;
/// <summary>Max augmentation bonus-burden contribution per Strength (retail clamp 0x96).</summary>
public const int AugBurdenCap = 150;
/// <summary>
/// Retail <c>EncumbranceSystem::EncumbranceCapacity(strength, aug)</c>
/// (named-retail decomp 256393, <c>0x004fcc00</c>):
/// <c>strength &lt;= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength</c>.
/// </summary>
public static int EncumbranceCapacity(int strength, int aug)
{
if (strength <= 0) return 0;
int bonus = aug * AugBurdenPerRank;
if (bonus < 0) bonus = 0; // decomp: eax_2 < 0 -> base only
if (bonus > AugBurdenCap) bonus = AugBurdenCap;
return strength * BurdenPerStrength + bonus * strength;
}
/// <summary>
/// Retail <c>EncumbranceSystem::Load(capacity, burden)</c> (decomp 256413,
/// <c>0x004fcc40</c>): the encumbrance ratio <c>burden / capacity</c>
/// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to
/// <c>return burden</c>; the <c>cap &lt;= 0</c> guard + single divide is unambiguous.
/// Returns 0 when capacity &lt;= 0 (no-data).
/// </summary>
public static float LoadRatio(int capacity, int burden)
=> capacity <= 0 ? 0f : (float)burden / capacity;
/// <summary>
/// Retail <c>gmBackpackUI::SetLoadLevel</c> bar fill (decomp 176542,
/// <c>0x004a6ea6</c>): <c>clamp(load * 0.3333…, 0, 1)</c> — the bar is 1/3 full at
/// 100% capacity, full at 300%.
/// </summary>
public static float LoadToFill(float load)
{
float fill = load / 3f;
if (fill < 0f) return 0f;
return fill > 1f ? 1f : fill;
}
/// <summary>
/// Retail <c>gmBackpackUI::SetLoadLevel</c> percent text (decomp 176542-176576): the
/// percent is computed from the CLAMPED fill — <c>arg2 = load/3</c> is clamped to [0,1]
/// (the 176544-176563 block sets <c>arg2 = 1.0</c> when fill &gt;= 1) BEFORE
/// <c>floor(arg2 * 300)</c>. So the number SATURATES at 300% (it does NOT read 400% at
/// 4x capacity). Equivalent to <c>floor(LoadToFill(load) * 300)</c>.
/// </summary>
public static int LoadToPercent(float load)
=> (int)System.MathF.Floor(LoadToFill(load) * 300f);
public static int ComputeMax(int strength, int bonusBurden)
=> BurdenPerStrength * strength + strength * bonusBurden;
public static int ComputeCarryLimit(int strength, int bonusBurden)
=> 3 * ComputeMax(strength, bonusBurden);
/// <summary>
/// Retail's "encumbered" multiplier interpolates between 1.0 at
/// zero burden and a low value at max. See r06 §6 for the curve.
/// </summary>
public static float ComputeEncumbranceMod(int currentBurden, int maxBurden)
{
if (maxBurden <= 0) return 1f;
float ratio = (float)currentBurden / maxBurden;
// Roughly 1.0 until 50%, then linear decay to ~0.7 at 100%, 0.1 at 300%.
if (ratio <= 0.5f) return 1f;
if (ratio <= 1.0f) return 1f - (ratio - 0.5f) * 0.6f; // 1.0 → 0.7
if (ratio <= 3.0f) return 0.7f - (ratio - 1.0f) * 0.3f; // 0.7 → 0.1
return 0.1f;
}
}