merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch
Brings the 134 D.2b UI commits onto the physics/collision development line so main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open doors fully passable) + the full collision/streaming/dense-town-FPS arc meet the paperdoll/inventory work. # Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
01594b4cfd
95 changed files with 17201 additions and 167 deletions
|
|
@ -57,46 +57,48 @@ public enum ItemType : uint
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equipment slot bitmask. 31 slots from head to Aetheria. Paperdoll
|
||||
/// widget offsets <c>+0x604..+0x660</c> in the retail panel correspond
|
||||
/// to these bits 1:1 (see r06 §2 and UI slice 05 paperdoll section).
|
||||
/// 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 = 0,
|
||||
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,
|
||||
HandArmor = 0x00002000,
|
||||
UpperLegArmor = 0x00004000,
|
||||
LowerLegArmor = 0x00008000,
|
||||
FootArmor = 0x00010000,
|
||||
Necklace = 0x00020000,
|
||||
LeftBracelet = 0x00040000,
|
||||
RightBracelet = 0x00080000,
|
||||
LeftRing = 0x00100000,
|
||||
RightRing = 0x00200000,
|
||||
MeleeWeapon = 0x00400000,
|
||||
Shield = 0x00800000,
|
||||
MissileWeapon = 0x01000000,
|
||||
Held = 0x02000000, // lit torch, book in hand
|
||||
MissileAmmo = 0x04000000,
|
||||
Cloak = 0x08000000,
|
||||
TrinketOne = 0x10000000,
|
||||
AetheriaRed = 0x20000000,
|
||||
AetheriaYellow= 0x40000000,
|
||||
AetheriaBlue = 0x80000000u,
|
||||
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>
|
||||
|
|
@ -216,6 +218,57 @@ 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>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;
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ public sealed class ClientObjectTable
|
|||
private readonly ConcurrentDictionary<uint, Container> _containers = new();
|
||||
private readonly Dictionary<uint, List<uint>> _containerIndex = new();
|
||||
|
||||
// B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE
|
||||
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
|
||||
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
|
||||
private readonly Dictionary<uint, (uint container, int slot, EquipMask equip, int outstanding)> _pendingMoves = new();
|
||||
|
||||
/// <summary>Fires when an object is first added to the session.</summary>
|
||||
public event Action<ClientObject>? ObjectAdded;
|
||||
|
||||
|
|
@ -114,7 +119,9 @@ public sealed class ClientObjectTable
|
|||
/// Handle a server-driven move — called from
|
||||
/// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023)
|
||||
/// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation
|
||||
/// and fires ObjectMoved.
|
||||
/// and fires ObjectMoved. Does NOT touch WielderId — neither the wield nor the
|
||||
/// unwield path manages it (a wielded item is modeled as contained-by-the-wielder),
|
||||
/// so RollbackMove restores full pre-move state through this method alone.
|
||||
/// </summary>
|
||||
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
|
||||
EquipMask newEquipLocation = EquipMask.None)
|
||||
|
|
@ -130,6 +137,103 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Snapshot the item's pre-move (container, slot, equip) the FIRST time it moves, and
|
||||
/// bump the OUTSTANDING count on subsequent in-flight moves of the same item (so an early confirm
|
||||
/// can't clear a still-pending later move — the I1 hardening). Shared by MoveItemOptimistic (unwield/
|
||||
/// move) and WieldItemOptimistic (wield).</summary>
|
||||
private void RecordPending(uint itemId, ClientObject item)
|
||||
{
|
||||
if (_pendingMoves.TryGetValue(itemId, out var p))
|
||||
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1);
|
||||
else
|
||||
_pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot,
|
||||
item.CurrentlyEquippedLocation, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST
|
||||
/// time it moves, then <see cref="MoveItem"/> (immediate repaint via ObjectMoved). The wire
|
||||
/// PutItemInContainer is sent by the caller; <see cref="ConfirmMove"/>/<see cref="RollbackMove"/>
|
||||
/// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile.
|
||||
/// </summary>
|
||||
public bool MoveItemOptimistic(uint itemId, uint newContainerId, int newSlot)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
// Snapshot the ORIGINAL position once + count OUTSTANDING optimistic moves of this item. The
|
||||
// count stops an early confirm (0x0022) for the first of several in-flight moves of the SAME
|
||||
// item from clearing the snapshot while a later move is still unconfirmed — else a later
|
||||
// reject would find nothing pending and strand the item at the rejected position.
|
||||
RecordPending(itemId, item);
|
||||
|
||||
// Gapless INSERT — treat newSlot as the insert INDEX, shift the other items, and renumber both
|
||||
// containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with
|
||||
// the item already at that slot; the sort-by-slot tie reshuffles the whole grid on every repaint
|
||||
// (the "items change position when I move one" bug). Retail's ItemList_InsertItem shifts the list.
|
||||
uint oldContainer = item.ContainerId;
|
||||
item.ContainerId = newContainerId;
|
||||
item.CurrentlyEquippedLocation = EquipMask.None;
|
||||
|
||||
if (oldContainer != 0 && oldContainer != newContainerId
|
||||
&& _containerIndex.TryGetValue(oldContainer, out var srcList))
|
||||
{ srcList.Remove(itemId); RenumberContainer(srcList); } // keep the source gapless too
|
||||
|
||||
if (newContainerId != 0)
|
||||
{
|
||||
if (!_containerIndex.TryGetValue(newContainerId, out var dstList))
|
||||
_containerIndex[newContainerId] = dstList = new List<uint>();
|
||||
dstList.Remove(itemId); // same-container move: pull out first
|
||||
int idx = (newSlot < 0 || newSlot > dstList.Count) ? dstList.Count : newSlot;
|
||||
dstList.Insert(idx, itemId);
|
||||
RenumberContainer(dstList); // sequential ContainerSlots → no ties
|
||||
}
|
||||
else item.ContainerSlot = newSlot;
|
||||
|
||||
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
|
||||
/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask — EXACTLY what the server's
|
||||
/// WieldObject 0x0023 confirm does via <see cref="MoveItem"/> (acdream models a wielded item as
|
||||
/// contained-by-the-wielder). Neither path writes WielderId, so the optimistic state equals the
|
||||
/// confirmed state and <see cref="RollbackMove"/> via MoveItem fully restores pre-move state. Fires
|
||||
/// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023
|
||||
/// echo) / RollbackMove (on 0x00A0) reconcile.</summary>
|
||||
public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
RecordPending(itemId, item);
|
||||
return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask);
|
||||
}
|
||||
|
||||
/// <summary>Assign each item in <paramref name="list"/> a sequential ContainerSlot (0..N-1) matching
|
||||
/// its list position — keeps a container gapless + collision-free so the grid order is stable.</summary>
|
||||
private void RenumberContainer(List<uint> list)
|
||||
{
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
if (_objects.TryGetValue(list[i], out var o)) o.ContainerSlot = i;
|
||||
}
|
||||
|
||||
/// <summary>The server confirmed a move (InventoryPutObjInContainer 0x0022 echo) — decrement the
|
||||
/// outstanding count; drop the snapshot only once no optimistic moves of this item remain. No-op
|
||||
/// for a server-initiated move we never tracked.</summary>
|
||||
public void ConfirmMove(uint itemId)
|
||||
{
|
||||
if (!_pendingMoves.TryGetValue(itemId, out var p)) return;
|
||||
if (p.outstanding <= 1) _pendingMoves.Remove(itemId);
|
||||
else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1);
|
||||
}
|
||||
|
||||
/// <summary>The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its
|
||||
/// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles
|
||||
/// any still-outstanding moves). False if nothing was pending.</summary>
|
||||
public bool RollbackMove(uint itemId)
|
||||
{
|
||||
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
|
||||
_pendingMoves.Remove(itemId);
|
||||
return MoveItem(itemId, pre.container, pre.slot, pre.equip);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a server-driven remove (destroyed item, dropped into 3D
|
||||
/// space, stolen, etc).
|
||||
|
|
@ -139,6 +243,7 @@ public sealed class ClientObjectTable
|
|||
if (!_objects.TryRemove(itemId, out var item)) return false;
|
||||
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
|
||||
l.Remove(itemId);
|
||||
_pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
|
||||
ObjectRemoved?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -163,6 +268,32 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a <see cref="PropertyBundle"/> patch, creating the object if it does not
|
||||
/// exist yet. Used for the local player's own properties from PlayerDescription
|
||||
/// (0x0013), which may arrive BEFORE the player's CreateObject — unlike
|
||||
/// <see cref="UpdateProperties"/>, which no-ops on an unknown object. Fires
|
||||
/// ObjectAdded on create, else ObjectUpdated.
|
||||
/// </summary>
|
||||
public void UpsertProperties(uint guid, PropertyBundle incoming)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
bool existed = _objects.TryGetValue(guid, out var item);
|
||||
if (!existed || item is null)
|
||||
{
|
||||
item = new ClientObject { ObjectId = guid };
|
||||
_objects[guid] = item;
|
||||
}
|
||||
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
|
||||
if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
|
||||
/// object: store it in the bundle and, for known typed ints, mirror to the typed
|
||||
|
|
@ -179,6 +310,20 @@ public sealed class ClientObjectTable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a SetStackSize (0x0197) update: set the object's StackSize + Value and
|
||||
/// fire ObjectUpdated so bound widgets refresh the quantity overlay. False if the
|
||||
/// object is unknown. Retail: ACCWeenieObject::ServerSaysSetStackSize (0x0058...).
|
||||
/// </summary>
|
||||
public bool UpdateStackSize(uint guid, int stackSize, int value)
|
||||
{
|
||||
if (!_objects.TryGetValue(guid, out var item)) return false;
|
||||
item.StackSize = stackSize;
|
||||
item.Value = value;
|
||||
ObjectUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical CreateObject ingestion: create-if-absent, else patch the
|
||||
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
|
||||
|
|
@ -274,6 +419,85 @@ public sealed class ClientObjectTable
|
|||
_containerIndex.TryGetValue(containerId, out var l)
|
||||
? l.ToArray() : System.Array.Empty<uint>();
|
||||
|
||||
/// <summary>
|
||||
/// Replace a container's entire membership with <paramref name="guids"/> (in order) — the
|
||||
/// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a
|
||||
/// container. Members no longer present are detached (ContainerId → 0); new members are
|
||||
/// recorded with ContainerSlot = list index. ACE writes ViewContents entries
|
||||
/// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the
|
||||
/// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints.
|
||||
/// Retail consumer: ClientUISystem::OnViewContents.
|
||||
/// </summary>
|
||||
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(guids);
|
||||
if (containerId == 0) return;
|
||||
|
||||
var keep = new HashSet<uint>(guids);
|
||||
|
||||
// Detach prior members no longer present (they left the container server-side). GetContents
|
||||
// returns a snapshot, so mutating the index inside the loop is safe.
|
||||
foreach (var old in GetContents(containerId))
|
||||
{
|
||||
if (keep.Contains(old)) continue;
|
||||
if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue;
|
||||
o.ContainerId = 0;
|
||||
Reindex(o, containerId);
|
||||
ObjectMoved?.Invoke(o, containerId, 0);
|
||||
}
|
||||
|
||||
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
|
||||
for (int i = 0; i < guids.Count; i++)
|
||||
{
|
||||
uint g = guids[i];
|
||||
bool existed = _objects.TryGetValue(g, out var obj);
|
||||
if (!existed || obj is null)
|
||||
{
|
||||
obj = new ClientObject { ObjectId = g };
|
||||
_objects[g] = obj;
|
||||
}
|
||||
uint oldContainer = obj.ContainerId;
|
||||
obj.ContainerId = containerId;
|
||||
obj.ContainerSlot = i;
|
||||
Reindex(obj, oldContainer);
|
||||
if (!existed) ObjectAdded?.Invoke(obj);
|
||||
else ObjectMoved?.Invoke(obj, oldContainer, containerId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Σ Burden over every object carried by <paramref name="ownerGuid"/>: items
|
||||
/// whose container chain roots at the owner (pack + side-bag contents, retail
|
||||
/// hierarchy is 2-deep) plus items wielded by the owner. The client-side
|
||||
/// equivalent of the server's <c>EncumbranceVal</c> (PropertyInt 5) — used by
|
||||
/// the inventory burden bar until the wire value is parsed (B-Wire). The hop
|
||||
/// cap (8) is a cycle guard; real chains are ≤2.
|
||||
/// </summary>
|
||||
public int SumCarriedBurden(uint ownerGuid)
|
||||
{
|
||||
int total = 0;
|
||||
foreach (var o in _objects.Values)
|
||||
if (IsCarriedBy(o, ownerGuid))
|
||||
total += o.Burden;
|
||||
return total;
|
||||
}
|
||||
|
||||
// NOTE: WielderId is populated from wire data only (CreateObject → Ingest). An
|
||||
// optimistically-wielded item (WieldItemOptimistic, before the server confirm) has
|
||||
// WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder).
|
||||
// A future "is this wielded by me?" check must therefore use ContainerId, NOT WielderId alone.
|
||||
private bool IsCarriedBy(ClientObject o, uint ownerGuid)
|
||||
{
|
||||
if (o.WielderId == ownerGuid) return true;
|
||||
uint c = o.ContainerId;
|
||||
for (int hops = 0; c != 0 && hops < 8; hops++)
|
||||
{
|
||||
if (c == ownerGuid) return true;
|
||||
c = _objects.TryGetValue(c, out var parent) ? parent.ContainerId : 0u;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flush the table — typically called on logoff or teleport
|
||||
/// that drops the session's object state.
|
||||
|
|
@ -283,5 +507,6 @@ public sealed class ClientObjectTable
|
|||
_objects.Clear();
|
||||
_containers.Clear();
|
||||
_containerIndex.Clear();
|
||||
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
src/AcDream.Core/Items/ShortcutStore.cs
Normal file
34
src/AcDream.Core/Items/ShortcutStore.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail
|
||||
/// <c>ShortCutManager::shortCuts_[18]</c> (acclient.h:36492). Holds the bound object guid per
|
||||
/// slot (0 = empty). Loaded from the login shortcut list, then mutated by drag-drop (lift
|
||||
/// removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the store
|
||||
/// stays the client's source of truth within a session (retail: client owns the array).
|
||||
/// Item shortcuts only — entries with ObjGuid 0 (spell-only) are skipped on Load. Pure model
|
||||
/// (no Core.Net dependency): callers project their wire entries to (slot, objGuid) pairs.
|
||||
/// </summary>
|
||||
public sealed class ShortcutStore
|
||||
{
|
||||
public const int SlotCount = 18;
|
||||
private readonly uint[] _objIds = new uint[SlotCount];
|
||||
|
||||
/// <summary>Replace all slots from a (slot, objectGuid) sequence (item entries only;
|
||||
/// ObjGuid 0 and out-of-range slots are skipped).</summary>
|
||||
public void Load(IEnumerable<(int Slot, uint ObjGuid)> entries)
|
||||
{
|
||||
Array.Clear(_objIds);
|
||||
foreach (var (slot, objGuid) in entries)
|
||||
if ((uint)slot < SlotCount && objGuid != 0) _objIds[slot] = objGuid;
|
||||
}
|
||||
|
||||
/// <summary>Bound object guid at <paramref name="slot"/>, or 0 (empty / out of range).</summary>
|
||||
public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u;
|
||||
public bool IsEmpty(int slot) => Get(slot) == 0u;
|
||||
public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; }
|
||||
public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue