acdream/src/AcDream.Core/Items/ClientObject.cs

596 lines
26 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. Verbatim retail <c>ITEM_TYPE</c>
/// (<c>docs/research/named-retail/acclient.h:3300</c>).
///
/// <para>The craft ladder used to be shifted: <c>CraftAlchemyIntermediate</c> sat on
/// 0x02000000 (retail leaves that bit unused and puts alchemy-intermediate on
/// 0x04000000), and an invented <c>CraftCookingIntermediate</c> 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 <c>Weapon</c> an alias of <c>WeaponOrCaster</c> and left <c>Item</c> two orders
/// of magnitude narrower than retail's. Full bit list in the research doc §1.</para>
/// </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,
// 0x02000000 is deliberately unused in retail's ladder.
CraftAlchemyIntermediate= 0x04000000,
CraftFletchingIntermediate = 0x08000000,
LifeStone = 0x10000000,
TinkeringTool = 0x20000000,
TinkeringMaterial = 0x40000000,
Gameboard = 0x80000000u,
// 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
}
/// <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.
/// (A remark here used to claim the header's <c>CLOTHING_LOC</c> composite sets an
/// unnamed bit 31. It does not: <c>CLOTHING_LOC</c> is 0x080001FF, the nine wear slots
/// plus bit 27, which is the named <see cref="EquipMask.Cloak"/> slot. <c>ALL_LOC</c>
/// tops out at bit 30, so no INVENTORY_LOC member uses bit 31 at all.)
/// </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,
// Retail's composite slot groups, transcribed verbatim from the same header
// block rather than recomputed from the primitives above. Clothing in
// particular is not the union of the wear slots — it also carries the cloak
// slot (bit 27).
Clothing = 0x080001FF, // CLOTHING_LOC: the nine wear slots | Cloak
Armor = 0x00007E00, // ARMOR_LOC
Jewelry = 0x7C0F8000, // JEWELRY_LOC
WristWear = 0x00030000, // WRIST_WEAR_LOC
FingerWear = 0x000C0000, // FINGER_WEAR_LOC
Sigil = 0x70000000, // SIGIL_LOC
ReadySlot = 0x03F00000, // READY_SLOT_LOC
Weapon = 0x02500000, // WEAPON_LOC
WeaponReadySlot = 0x03500000, // WEAPON_READY_SLOT_LOC
All = 0x7FFFFFFF, // ALL_LOC
/// <summary>Retail gives CAN_GO_IN_READY_SLOT_LOC the same value as ALL_LOC.</summary>
CanGoInReadySlot = 0x7FFFFFFF,
}
/// <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 long GetInt64 (uint k, long def = 0) => Int64s.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;
public PropertyBundle Clone()
{
var copy = new PropertyBundle();
foreach (var kv in Ints) copy.Ints[kv.Key] = kv.Value;
foreach (var kv in Int64s) copy.Int64s[kv.Key] = kv.Value;
foreach (var kv in Bools) copy.Bools[kv.Key] = kv.Value;
foreach (var kv in Floats) copy.Floats[kv.Key] = kv.Value;
foreach (var kv in Strings) copy.Strings[kv.Key] = kv.Value;
foreach (var kv in DataIds) copy.DataIds[kv.Key] = kv.Value;
foreach (var kv in InstanceIds) copy.InstanceIds[kv.Key] = kv.Value;
return copy;
}
}
/// <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
{
private uint? _houseOwnerId;
private uint? _monarchId;
private HouseRestrictionRecord? _restrictions;
/// <summary>
/// Synchronous table-internal notification for the qualities consulted by
/// retail's house-entry restriction gate. Multiple tables may retain the
/// same object during isolated evaluation fixtures; each receives the edge.
/// </summary>
internal event Action<ClientObject>? RestrictionAuthorityChanged;
public uint ObjectId { get; init; }
public uint WeenieClassId { get; set; } // "blueprint"
public string Name { get; set; } = "";
/// <summary>Retail <c>PublicWeenieDesc._plural_name</c>; empty falls back to singular.</summary>
public string PluralName { 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;
/// <summary>
/// Retail <c>ContentProfile.m_uContainerProperties</c> hint from
/// PlayerDescription/ViewContents: 0 = loose item list, nonzero = container list.
/// Kept separate from <see cref="Type"/> because CreateObject owns real item data.
/// </summary>
public uint ContainerTypeHint { get; set; }
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)
/// <summary>Retail <c>PublicWeenieDesc._hook_item_types</c>.</summary>
public uint HookItemTypes { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._hook_type</c>.</summary>
public uint HookType { get; set; }
/// <summary>
/// Port of <c>ACCWeenieObject::IsHook @ 0x0058C660</c>.
/// A hook must publish both a hook type and an accepted item-type mask.
/// </summary>
public bool IsHook => HookType != 0u && HookItemTypes != 0u;
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
/// <summary>Retail <c>PublicWeenieDesc._bitfield</c>; null until CreateObject supplies it.</summary>
public uint? PublicWeenieBitfield { get; set; }
/// <summary>
/// Retail <c>PublicWeenieDesc._pet_owner</c>; zero means this creature is not a pet.
/// CreateObject second-header flag <c>PWD2_Packed_PetOwner (0x8)</c> supplies it.
/// </summary>
public uint PetOwnerId { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._combatUse</c>; null when its header flag was absent.</summary>
public byte? CombatUse { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._ammoType</c>; zero is <c>AMMO_NONE</c>.</summary>
public ushort? AmmoType { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
public uint? SpellId { get; set; }
/// <summary>
/// Retail <c>PublicWeenieDesc._cooldown_id</c>. Positive values name a
/// shared item-cooldown group whose player enchantment id is
/// <c>CooldownId + 0x8000</c>.
/// </summary>
public uint? CooldownId { get; set; }
/// <summary>
/// Retail <c>PublicWeenieDesc._cooldown_duration</c>, in seconds. This is
/// the denominator used by <c>UIElement_UIItem::UpdateCooldownDisplay</c>.
/// </summary>
public double? CooldownDuration { get; set; }
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
public int TradeState { get; set; }
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
public bool IsComponentPack { get; set; }
/// <summary>Retail PublicWeenieDesc <c>_blipColor</c>; null until supplied by CreateObject.</summary>
public byte? RadarBlipColor { get; set; }
/// <summary>Retail PublicWeenieDesc <c>_radar_enum</c>; null until supplied by CreateObject.</summary>
public byte? RadarBehavior { get; set; }
public int Structure { get; set; } // charges/uses remaining
public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
/// <summary>
/// Retail <c>PublicWeenieDesc._material_type</c>. The appropriate-name
/// path resolves this through the DAT material enum and prefixes it to
/// the base object name.
/// </summary>
public uint? MaterialType { get; set; }
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._house_owner_iid</c>. Present only on a house/dwelling restriction
/// object (wire <c>WeenieHeaderFlag.Owner</c>, 0x02000000). Zero or a
/// match against the mover's own id admits regardless of the guest list.
/// </summary>
public uint? HouseOwnerId
{
get => _houseOwnerId;
set
{
if (_houseOwnerId == value) return;
_houseOwnerId = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._monarch_iid</c> (wire <c>WeenieHeaderFlag.Monarch</c>, 0x40) — this
/// object's OWN allegiance monarch. Present on any weenie, not just house
/// objects; a player's own value is what <c>RestrictionDB::IsAllowedIn</c>
/// compares against a house's <see cref="HouseRestrictionRecord.AllegianceMonarchId"/>.
/// </summary>
public uint? MonarchId
{
get => _monarchId;
set
{
if (_monarchId == value) return;
_monarchId = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._db</c> (<c>RestrictionDB*</c>) — the house's own guest/ban list.
/// Null means retail's <c>_db == 0</c> ("no list", i.e. open) OR simply
/// that neither a CreateObject HouseRestrictions field nor a live
/// <c>House_UpdateRestrictions (0x0248)</c> has arrived yet for this
/// object — acdream cannot distinguish the two, and both resolve to
/// the same retail-faithful "allow" default.
/// </summary>
public HouseRestrictionRecord? Restrictions
{
get => _restrictions;
set
{
if (ReferenceEquals(_restrictions, value)) return;
_restrictions = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
public PropertyBundle Properties { get; } = new();
/// <summary>
/// Ports the singular/plural selection inside
/// <c>ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0</c>.
/// Stacked objects prefer <see cref="PluralName"/>; when the wire omitted it,
/// retail appends <c>s</c>, or <c>es</c> when the singular already ends in
/// lowercase s. DAT-backed material decoration belongs to the presentation
/// resolver because Core intentionally has no content-reader dependency.
/// </summary>
public string GetAppropriateName()
{
if (StackSize <= 1) return Name;
if (!string.IsNullOrEmpty(PluralName)) return PluralName;
if (string.IsNullOrEmpty(Name)) return Name;
return Name[^1] == 's' ? Name + "es" : Name + "s";
}
}
/// <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,
uint? Useability = null,
uint? TargetType = null,
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
uint? PublicWeenieBitfield = null,
byte? CombatUse = null,
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null,
uint? SpellId = null,
uint? CooldownId = null,
double? CooldownDuration = null,
uint? HookItemTypes = null,
uint? HookType = null,
uint? MaterialType = null,
// AP-129 (Campaign P Slice P4 review fix): house-restriction PWD tail
// fields. Restrictions is null-preserving (a CreateObject that doesn't
// carry HouseRestrictions this time must not clobber a value fed
// separately by a live House_UpdateRestrictions event).
uint? HouseOwnerId = null,
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
/// Low 16 bits describe where the source may be used from; high 16 bits
/// describe where the acquired target may be.
/// </summary>
public static class ItemUseability
{
public const uint Undef = 0x0u;
public const uint No = 0x1u;
public const uint Self = 0x2u;
public const uint Wielded = 0x4u;
public const uint Contained = 0x8u;
public const uint Viewed = 0x10u;
public const uint Remote = 0x20u;
public const uint NeverWalk = 0x40u;
public const uint ObjSelf = 0x80u;
public const uint SourceMask = 0x0000FFFFu;
public const uint TargetMask = 0xFFFF0000u;
public static bool IsTargeted(uint useability)
=> (useability & TargetMask) != 0;
public static bool AllowsSelfTarget(uint useability)
=> ((useability >> 16) & Self) != 0;
public static bool AllowsObjectSelfTarget(uint useability)
=> ((useability >> 16) & ObjSelf) != 0;
public static uint SourceFlags(uint useability)
=> useability & SourceMask;
/// <summary>Retail <c>ItemUses::GetLeastLimitedSourceUse</c>.</summary>
public static uint LeastLimitedSourceUse(uint useability)
{
uint s = SourceFlags(useability);
if ((s & Remote) != 0) return Remote;
if ((s & Viewed) != 0) return Viewed;
if ((s & Contained) != 0) return Contained;
if ((s & Wielded) != 0) return Wielded;
if ((s & Self) != 0) return Self;
return Undef;
}
public static uint TargetFlags(uint useability)
=> (useability & TargetMask) >> 16;
/// <summary>
/// Retail <c>ItemUses::IsUseable @ 0x004FCCC0</c>. The implementation
/// complements the full bitfield and returns its low bit, so only
/// <c>USEABLE_NO</c> disables use. In particular, the default
/// <c>USEABLE_UNDEF</c> value zero is usable.
/// </summary>
public static bool IsUseable(uint useability)
=> (useability & No) == 0;
/// <summary>
/// Retail <c>ItemUses::GetLeastLimitedTargetUse</c> (0x004fcd50): the most
/// permissive target-use bit present, priority Remote &gt; Viewed &gt;
/// Contained &gt; Wielded &gt; Self &gt; ObjSelf. The target-compat gate
/// (<c>ItemHolder::IsTargetCompatibleWithTargetingObject</c> 0x00588070)
/// only enforces a location constraint when the BEST available bit is
/// Contained or Wielded.
/// </summary>
public static uint LeastLimitedTargetUse(uint useability)
{
uint t = TargetFlags(useability);
if ((t & Remote) != 0) return Remote;
if ((t & Viewed) != 0) return Viewed;
if ((t & Contained) != 0) return Contained;
if ((t & Wielded) != 0) return Wielded;
if ((t & Self) != 0) return Self;
return t & ObjSelf;
}
public static bool IsDirectUseable(uint useability)
=> !IsTargeted(useability) && IsUseable(useability);
}
/// <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>EncumbranceSystem::LoadMod @ 0x004FCC70</c>: full
/// effectiveness through 100% load, a linear falloff to zero at 200%,
/// then zero above it.
/// </summary>
public static float LoadModifier(float load)
=> load <= 1f ? 1f : load < 2f ? 2f - load : 0f;
/// <summary>
/// Percentage printed by <c>gmCharacterInfoUI::UpdateLoad @ 0x004B8A20</c>.
/// Retail truncates <c>LoadMod * 10</c> before converting the ten steps to
/// a percentage.
/// </summary>
public static int LoadPenaltyPercent(float load)
=> (10 - (int)(LoadModifier(load) * 10f)) * 10;
/// <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;
}
}