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:
Erik 2026-06-25 12:57:46 +02:00
commit 01594b4cfd
95 changed files with 17201 additions and 167 deletions

View file

@ -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 &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;

View file

@ -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)
}
}

View 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; }
}