Campaign P Slice P4 Opus review verdict: FIX-FIRST. RestrictionObjPrevalenceInspectionTests
(commit 3b5e0992) found 103,766 of 729,888 installed EnvCells (1,293 landblocks -
the whole housing estate) carry a baked RestrictionObj. The AP-71 gate's
unconditional fail-closed default (CanMoveInto unmodeled) would have locked
every apartment/cottage/villa interior for every player, including its own
owner - a live regression, not the "inert in dev content" the original
register row assumed.
Ports ACCWeenieObject::CanMoveInto (0x0058da40, pc:407982-408056) and
RestrictionDB::IsAllowedIn (0x005ae8f0, pc:444493-444516) verbatim into
ObjectInfo.CheckEntryRestrictions:
- owner_iid == 0 or == mover's own guid -> admit (open/owner)
- no RestrictionDB (retail _db == 0, i.e. never authored or not yet
received) -> admit
- present RestrictionDB -> IsAllowedIn: open-to-public flag, OR mover
shares the house's allegiance monarch, OR mover's own guid is a
guest-table member
- unresolved restriction object -> fails CLOSED, exactly retail's own
fallback when GetObjectA can't resolve it (pc:704-716)
Wire feed (Core.Net):
- CreateObject.cs: HouseOwner (WeenieHeaderFlag 0x02000000), HouseRestrictions
(0x04000000), and Monarch (0x40) PWD-tail fields were parsed-and-skipped;
now captured. Also fixes the HouseRestrictions PHashTable header
misconception: the wire is ONE packed u32 (low 24 bits = entry count),
not a separate count(u16)+numBuckets(u16) pair - verified against
Chorizite's RestrictionDB.generated.cs. The old skip's byte-count
happened to match for realistic guest-list sizes, but a future
numBuckets value >255 would have corrupted the parse; now correct
regardless.
- GameEvents.cs/GameEventWiring.cs: new House_UpdateRestrictions (0x0248)
parser + wiring - retail's live guest-list refresh, whole-unit replace.
No-ops if the house object hasn't arrived via CreateObject yet.
- ClientObject/WeenieData/ClientObjectTable: HouseOwnerId, MonarchId,
Restrictions (new HouseRestrictionRecord) fields + merge-preserving
Ingest + targeted UpdateHouseRestrictions.
Physics wiring:
- PhysicsEngine gains an Objects (ClientObjectTable?) property, mirroring
the existing DataCache pattern - acdream's GetObjectA equivalent, used
ONLY by the entry-restriction gate.
- RuntimeEntityObjectLifetime wires Physics.Engine.Objects = Objects in
all three constructors, right alongside the table's own construction -
the same canonical table every other subsystem borrows from, never a
second one. This is the production fix: without it the gate still fails
closed on every restricted cell (unresolvable object), so the wiring is
load-bearing, not cosmetic.
Register: AP-129 narrowed (not retired) to the genuine remaining residual -
House_UpdateRestrictions' Sequence byte isn't used for staleness/reordering
rejection (low-probability, self-correcting), and outdoor CLandCell
restriction (a separate DAT structure) remains unported and unaffected by
this fix.
Tests: 15 new/updated in Ap71EntryRestrictionGateTests.cs (resolved-unowned
admits, owner admits, present-list-excluded blocks, present-list-included
admits, open-to-public admits, shared-allegiance-monarch admits, unresolved
blocks via null and via an empty table, plus two new end-to-end
PhysicsEngine.Objects-wired scenarios); 2 new CreateObject parser tests +
2 new GameEventWiring tests for the wire feed.
AcDream.Core.Tests: 4049 passed, 2 skipped, 0 failed.
AcDream.Core.Net.Tests: 761 passed, 0 skipped, 0 failed.
Complete solution suite: 9,961 total, 9,956 passed, 5 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
558 lines
26 KiB
C#
558 lines
26 KiB
C#
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
|
||
{
|
||
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; set; }
|
||
/// <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; set; }
|
||
/// <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; set; }
|
||
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 > Viewed >
|
||
/// Contained > Wielded > Self > 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 <= 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 <= 0</c> guard + single divide is unambiguous.
|
||
/// Returns 0 when capacity <= 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 >= 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;
|
||
}
|
||
}
|