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

@ -663,6 +663,10 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.Layout.ToolbarController? _toolbarController;
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
private AcDream.App.UI.Layout.PaperdollController? _paperdollController;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
@ -2059,7 +2063,9 @@ public sealed class GameWindow : IDisposable
combatState: Combat,
peaceDigits: toolbarPeaceDigits,
warDigits: toolbarWarDigits,
emptyDigits: toolbarEmptyDigits);
emptyDigits: toolbarEmptyDigits,
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
// Phase D.5.3a — selected-object strip (name, overlay state, health meter).
// Analogue of retail gmToolbarUI::HandleSelectionChanged
@ -2083,7 +2089,7 @@ public sealed class GameWindow : IDisposable
// thickness on every side, giving an outer window of 310×132.
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
var toolbarFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome)
{
Left = 10, Top = 300,
Width = toolbarContentW + 2 * toolbarBorder,
@ -2096,14 +2102,59 @@ public sealed class GameWindow : IDisposable
toolbarRoot.Top = toolbarBorder;
toolbarRoot.Width = toolbarContentW;
toolbarRoot.Height = toolbarContentH;
// Anchor content to all four edges so it reflows if the frame is resized.
// Anchor content to Left|Top|Right only — drop Bottom so the dat content
// keeps its full height when the frame collapses; row 2 hides via Visible,
// not reflow. (Width is fixed so the horizontal anchors are inert but harmless.)
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom;
// The frame is the draggable window; the content itself is not.
toolbarRoot.ClickThrough = false;
| AcDream.App.UI.AnchorEdges.Right;
// The frame is the draggable window; the content itself is not. ClickThrough so the
// content panel never CLAIMS a hit — its behavioral children (slots, indicators) are
// hit children-first, and its empty areas fall through to the frame (move). Critically,
// when collapsed the row-2 band is hidden, so below the collapsed frame the content has
// no visible/hit children and ClickThrough lets clicks fall through (no phantom
// window-drag from where row 2 used to be). Same pattern as the chat content panel.
toolbarRoot.ClickThrough = true;
toolbarRoot.Draggable = false;
toolbarRoot.Resizable = false;
toolbarFrame.AddChild(toolbarRoot);
// Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden)
// and two-row height. CollapsedHeight is computed from the layout (just above row 2),
// so there's no magic constant. Bottom-edge only; default expanded.
// The full row-2 band (all at content-y 90, toolbar dump 0x21000016): a 6px left
// edge-piece (0x100006B6) + the 9 slots (0x100006B7..BF) + an 8px right edge-piece
// (0x100006C0). Hide ALL 11 when collapsed — hiding only the 9 slots leaves the two
// edge-pieces drawing as black pillars below the bar.
uint[] row2Ids =
{
0x100006B6u,
0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu,
0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu,
0x100006C0u,
};
var toolbarRow2 = new System.Collections.Generic.List<AcDream.App.UI.UiElement>();
float minRow2Top = float.MaxValue;
foreach (var id in row2Ids)
if (toolbarLayout.FindElement(id) is { } e2)
{
toolbarRow2.Add(e2);
if (e2.Top < minRow2Top) minRow2Top = e2.Top;
}
if (toolbarRow2.Count > 0)
{
float expandedH = toolbarContentH + 2 * toolbarBorder; // today's full height
float collapsedH = minRow2Top + 2 * toolbarBorder; // just above row 2
toolbarFrame.CollapsedHeight = collapsedH;
toolbarFrame.ExpandedHeight = expandedH;
toolbarFrame.SecondRow = toolbarRow2;
toolbarFrame.Resizable = true;
toolbarFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; // bottom edge only
toolbarFrame.MinHeight = collapsedH;
toolbarFrame.MaxHeight = expandedH;
// Height stays expandedH (already set at construction) = default expanded.
Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0}).");
}
_uiHost.Root.AddChild(toolbarFrame);
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
@ -2131,6 +2182,125 @@ public sealed class GameWindow : IDisposable
}
}
}
// Phase D.2b-B — the real inventory window from LayoutDesc 0x21000023 (gmInventoryUI),
// via the LayoutImporter. The sub-window mount pulls in the nested paperdoll/backpack/
// 3D-items panels (Type-0 leaves inheriting BaseLayoutId 0x21000024/22/21). Starts
// HIDDEN; F12 toggles it via the window manager. Position is the dat's own (X=500,Y=138).
AcDream.App.UI.Layout.ImportedLayout? invLayout;
lock (_datLock)
invLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, 0x21000023u, ResolveChrome, vitalsDatFont);
if (invLayout is not null)
{
var inventoryRoot = invLayout.Root;
// Wrap the dat content in the universal 8-piece beveled window chrome — the
// SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI
// LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on
// every side. The frame is the draggable window; the dat content sits inside it.
const int invBorder = AcDream.App.UI.RetailChromeSprites.Border;
float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height;
var inventoryFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
{
Left = inventoryRoot.Left, Top = inventoryRoot.Top, // keep the dat window position
Width = invContentW + 2 * invBorder,
Height = invContentH + 2 * invBorder,
Opacity = 1.0f,
Visible = false, // F12 toggles it
Anchors = AcDream.App.UI.AnchorEdges.None, // user-positioned
Draggable = true,
};
// Content offset by the border so it sits inside the chrome.
inventoryRoot.Left = invBorder; inventoryRoot.Top = invBorder;
inventoryRoot.Width = invContentW; inventoryRoot.Height = invContentH;
// Content fills the frame vertically (Left|Top|Bottom = fixed width + height tracks
// the frame) so a vertical resize grows the inner content.
inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Bottom;
inventoryRoot.Draggable = false;
inventoryFrame.AddChild(inventoryRoot);
// Vertical-only resize: drag the BOTTOM edge to expand and see more of the pack.
// ResizeX=false + ResizableEdges=Bottom blocks horizontal resize (user request).
// MinHeight = the dat default (no shrink below it); MaxHeight = toward full screen.
inventoryFrame.Resizable = true;
inventoryFrame.ResizeX = false;
inventoryFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom;
inventoryFrame.MinHeight = invContentH + 2 * invBorder;
inventoryFrame.MaxHeight = 560f;
// Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid
// + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the
// paperdoll + side-bag column stay pinned at the top (Left|Top). The grid's ViewHeight
// grows → more rows + the scrollbar thumb ratio (view/content) reflects it.
void StretchV(uint id)
{
if (invLayout!.FindElement(id) is { } el)
el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Bottom;
}
void PinTopLeft(uint id)
{
if (invLayout!.FindElement(id) is { } el)
el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top;
}
StretchV(0x100001D0u); // full-window backdrop
StretchV(0x100001CFu); // gm3DItemsUI sub-window (contents-grid container)
StretchV(0x100001C6u); // contents grid (UiItemList)
StretchV(0x100001C7u); // contents scrollbar
PinTopLeft(0x100001CDu); // paperdoll (fixed at top)
PinTopLeft(0x100001CEu); // side-bag column (fixed at top)
_uiHost.Root.AddChild(inventoryFrame);
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryFrame);
// Phase D.2b-B — populate the inventory from ClientObjectTable: the
// "Contents of Backpack" grid + the pack-selector strip + the burden meter +
// captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit.
// Resolve each inventory list's empty-slot art from the dat cell template
// (retail UIElement_ItemList::InternalCreateItem 0x004e3570: attr 0x1000000e ->
// catalog 0x21000037 prototype's ItemSlot_Empty). The contents grid lives in
// gm3DItemsUI (0x21000021); the side-bag + main-pack in gmBackpackUI (0x21000022).
uint contentsEmpty, sideBagEmpty, mainPackEmpty;
lock (_datLock)
{
contentsEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000021u, 0x100001C6u);
sideBagEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001CAu);
mainPackEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001C9u);
}
Console.WriteLine($"[D.2b empty-slot] contents=0x{contentsEmpty:X8} sideBag=0x{sideBagEmpty:X8} mainPack=0x{mainPackEmpty:X8}");
_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(
invLayout, Objects,
playerGuid: () => _playerServerGuid,
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
strength: () => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa
? (int?)sa.Current : null,
datFont: vitalsDatFont,
contentsEmptySprite: contentsEmpty,
sideBagEmptySprite: sideBagEmpty,
mainPackEmptySprite: mainPackEmpty,
sendUse: g => _liveSession?.SendUse(g),
sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g),
sendPutItemInContainer: (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement));
// Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear +
// wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid).
_paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind(
invLayout, Objects,
playerGuid: () => _playerServerGuid,
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
// Empty equip slots show a visible frame (same square as the inventory grid) so every
// slot position is seen + usable; the live 3D character (the doll) is Slice 2.
emptySlotSprite: contentsEmpty);
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
}
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
}
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
@ -2433,7 +2603,7 @@ public sealed class GameWindow : IDisposable
// D.5.4: ingest CreateObject into the object table (upsert) and wire Delete +
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
// the table is populated before the render handler runs.
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects);
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid);
_liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.EntityDeleted += OnLiveEntityDeleted;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
@ -2531,7 +2701,8 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
}
},
onShortcuts: list => Shortcuts = list);
onShortcuts: list => Shortcuts = list,
playerGuid: () => _playerServerGuid);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
@ -11709,6 +11880,13 @@ public sealed class GameWindow : IDisposable
switch (action)
{
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
// does not fire while the chat input holds focus. Null _uiHost =
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
_uiHost?.ToggleWindow(AcDream.App.UI.WindowNames.Inventory);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
foreach (var panel in EnumerateDebugPanel())
{

View file

@ -0,0 +1,25 @@
namespace AcDream.App.UI;
/// <summary>
/// A panel controller implements this and registers itself on each of its
/// <see cref="UiItemList"/>s. Port of retail's <c>m_dragHandler</c> vtable
/// (<c>RegisterItemListDragHandler</c>, decomp 230461; confirmed acclient
/// 0x004a539e + the gmToolbarUI block 0x004bdd89).
/// <para><see cref="OnDragOver"/> decides the accept/reject OVERLAY only (advisory).
/// <see cref="HandleDropRelease"/> is authoritative — it performs the action, or
/// no-ops to reject.</para>
/// </summary>
public interface IItemListDragHandler
{
/// <summary>The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag
/// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its
/// model + wire so the source slot empties immediately. The item is "in hand" until
/// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel.</summary>
void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload);
/// <summary>True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red).</summary>
bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
/// <summary>Perform the drop (issue the per-panel wire action), or no-op to reject.</summary>
void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload);
}

View file

@ -0,0 +1,24 @@
namespace AcDream.App.UI;
/// <summary>
/// Where a dragged item came from — the retail <c>InqDropIconInfo</c> flag
/// distinction (<c>flags &amp; 0xE == 0</c> fresh-from-inventory vs
/// <c>flags &amp; 4</c> within-list reorder) expressed as a typed enum. The drop
/// handler maps SourceKind + target back to the fresh-vs-reorder decision.
/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533.
/// </summary>
public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground }
/// <summary>
/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving
/// mid-drag can't mutate it under us). Port of retail's <c>m_dragElement</c> +
/// <c>InqDropIconInfo</c> out-params (objId/container/flags, decomp 230533).
/// <para><c>SourceContainer</c> is intentionally NOT stored: the handler resolves the
/// LIVE container via <c>ClientObjectTable.Get(ObjId).ContainerId</c> at drop — the
/// same container id retail reads off the dragged element, single source of truth.</para>
/// </summary>
public sealed record ItemDragPayload(
uint ObjId, // dragged weenie guid (retail itemID, +0x5FC)
ItemDragSource SourceKind, // what kind of slot it left
int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged)
UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost

View file

@ -77,8 +77,11 @@ public static class DatWidgetFactory
e.Width = info.Width;
e.Height = info.Height;
// Honor the dat's draw order so overlapping pieces (grip overlay over bevel chrome) layer correctly.
e.ZOrder = (int)info.ReadOrder;
// Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK — e.g. the
// gmInventoryUI full-window backdrop at ZLevel 100 sits behind the ZLevel-0 panels, #145);
// ReadOrder is the within-layer tiebreaker (higher = on top). K=10000 exceeds any window's
// element count so ZLevel always dominates. Vitals (all ZLevel 0) keep ZOrder == ReadOrder.
e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000;
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
// UI layout engine uses for reflow.

View file

@ -39,6 +39,12 @@ public sealed class ElementInfo
/// <summary>Draw order within the parent (lower = drawn first / behind).</summary>
public uint ReadOrder;
/// <summary>Layer level from the dat (<c>ElementDesc.ZLevel</c>). Higher = drawn further
/// BACK (a full-window backdrop at ZLevel 100 sits behind ZLevel-0 panels). The factory
/// folds it into <see cref="UiElement.ZOrder"/> so ZLevel dominates and ReadOrder is the
/// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected).</summary>
public uint ZLevel;
/// <summary>
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
@ -152,6 +158,7 @@ public static class ElementReader
Right = derived.Right,
Bottom = derived.Bottom,
ReadOrder = derived.ReadOrder,
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,

View file

@ -0,0 +1,491 @@
using System;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Items;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from
/// <see cref="ClientObjectTable"/>. The acdream analogue of retail
/// gmInventoryUI/gmBackpackUI/gm3DItemsUI ::PostInit (named-retail decomp 176236/176596/176728).
/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace);
/// drag-into-bag / wield-drop wire are later sub-phases.
/// </summary>
public sealed class InventoryController : IItemListDragHandler
{
// Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack")
public const uint ContainerListId = 0x100001CAu; // gmBackpackUI m_containerList (side-bag selector)
public const uint TopContainerId = 0x100001C9u; // gmBackpackUI m_topContainer (main-pack cell)
public const uint BurdenMeterId = 0x100001D9u; // gmBackpackUI m_burdenMeter (vertical)
public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%")
public const uint BurdenCaptionId = 0x100001D7u; // "Burden"
public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
// ChatWindowController). Both inventory + chat scrollbars inherit this base.
private const uint ScrollTrackSprite = 0x06004C5Fu;
private const uint ScrollThumbSprite = 0x06004C63u; // 3-slice middle tile
private const uint ScrollThumbTop = 0x06004C60u; // 3-slice top cap
private const uint ScrollThumbBot = 0x06004C66u; // 3-slice bottom cap
private const uint ScrollUpSprite = 0x06004C6Cu; // up arrow (top button)
private const uint ScrollDownSprite = 0x06004C69u; // down arrow (bottom button)
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
private const int ContentsColumns = 6;
private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px)
private const float BackpackCellPx = 36f; // gmBackpackUI column cells (0x100001C9/CA = 36px)
private const int SideBagSlots = 7; // 0x100001CA is 36x252 = 7 slots
private const int MainPackSlots = 102; // retail main-pack capacity (Wiki: "up to 102 items")
private const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll)
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<int?> _strength;
private readonly UiItemList? _contentsGrid;
private readonly UiItemList? _containerList;
private readonly UiItemList? _topContainer;
private readonly UiMeter? _burdenMeter;
private float _burdenFill;
private int _burdenPercent;
private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
private uint _selectedItem; // 0 = none; the panel-wide selected item (green square)
private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
private const uint EncumbranceValProperty = 5u; // total carried burden
private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation
private InventoryController(
ImportedLayout layout,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
UiDatFont? datFont,
uint contentsEmptySprite,
uint sideBagEmptySprite,
uint mainPackEmptySprite,
Action<uint>? sendUse,
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer)
{
_objects = objects;
_playerGuid = playerGuid;
_iconIds = iconIds;
_strength = strength;
_sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
_containerList = layout.FindElement(ContainerListId) as UiItemList;
_topContainer = layout.FindElement(TopContainerId) as UiItemList;
if (_contentsGrid is not null)
{
_contentsGrid.Columns = ContentsColumns;
_contentsGrid.CellWidth = ContentsCellPx;
_contentsGrid.CellHeight = ContentsCellPx;
}
// Bind the gutter scrollbar to the contents grid's scroll model (the factory built
// 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does).
if (_contentsGrid is not null
&& layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
{
bar.Model = _contentsGrid.Scroll;
bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor
bar.TrackSprite = ScrollTrackSprite;
bar.ThumbSprite = ScrollThumbSprite;
bar.ThumbTopSprite = ScrollThumbTop;
bar.ThumbBotSprite = ScrollThumbBot;
bar.UpSprite = ScrollUpSprite;
bar.DownSprite = ScrollDownSprite;
}
if (_containerList is not null)
{
_containerList.Columns = 1;
_containerList.CellWidth = BackpackCellPx;
_containerList.CellHeight = BackpackCellPx;
}
// Per-list empty-slot art, resolved from the dat cell template (retail attr 0x1000000e
// -> catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the UiItemSlot default.
if (_contentsGrid is not null) _contentsGrid.CellEmptySprite = contentsEmptySprite;
if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite;
if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite;
// B-Drag: register this controller as the drag handler on all three lists.
_contentsGrid?.RegisterDragHandler(this);
_containerList?.RegisterDragHandler(this);
_topContainer?.RegisterDragHandler(this);
// Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4).
_burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter;
if (_burdenMeter is not null)
{
_burdenMeter.Vertical = true; // 11x58 vertical bar
_burdenMeter.FillFromBottom = true; // retail direction 4 (visual-confirmed)
_burdenMeter.Fill = () => _burdenFill;
}
// Captions: drive each host UiText directly with the known string (the caption
// elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail
// (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.)
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
// Rebuild on any change to the player's possessions.
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
Populate();
}
public static InventoryController Bind(
ImportedLayout layout,
ClientObjectTable objects,
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
UiDatFont? datFont,
uint contentsEmptySprite = 0u,
uint sideBagEmptySprite = 0u,
uint mainPackEmptySprite = 0u,
Action<uint>? sendUse = null,
Action<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
private bool Concerns(ClientObject o)
{
uint p = _playerGuid();
return o.ObjectId == p // the player object IS the burden source
|| o.ContainerId == p || o.WielderId == p
|| o.ContainerId == EffectiveOpen() // the OPEN container's contents drive the grid
|| (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep
}
public void Populate()
{
uint p = _playerGuid();
uint open = EffectiveOpen();
_contentsGrid?.Flush();
_containerList?.Flush();
// Side-bag column: ALWAYS the player's bags (constant across container switches; only the
// open/selected indicators move). Equipped items never appear here.
foreach (var guid in _objects.GetContents(p))
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
if (isBag) AddCell(_containerList, guid, isContainer: true);
}
// Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has
// no sub-bags, so the isBag skip is a no-op when a bag is open.)
foreach (var guid in _objects.GetContents(open))
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
}
// Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24).
if (_contentsGrid is not null)
{
int cap = _objects.Get(open)?.ItemsCapacity ?? 0;
int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots);
while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid);
}
// Pad the side-bag column to capacity, clamped to the 7-slot column (AP-52).
if (_containerList is not null)
{
int capacity = _objects.Get(p)?.ContainersCapacity ?? 0;
int bags = _containerList.GetNumUIItems();
int slots = capacity > 0 ? capacity : SideBagSlots;
slots = Math.Max(slots, bags);
slots = Math.Min(slots, SideBagSlots);
while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList);
}
// Main-pack cell: the player's own container — clicking it opens/selects the main pack.
// Retail draws a CONSTANT backpack icon here, NOT the player's body icon: IconData::RenderIcons
// (0x0058d1ee) has an IsThePlayer() branch that draws a fixed backpack with m_itemType =
// TYPE_CONTAINER. Compose that backpack base over the Container type-underlay (the player
// object's own IconId is the character body, which would render wrong here). The backpack
// RenderSurface is 0x0600127E, VISUALLY CONFIRMED at the live gate 2026-06-22 (the earlier
// 0x060011F4 from a research dat-dump of GetDIDByEnum(0x10000004,7) was a green tile, not the
// pack — the index value was misreported). Retires AP-51.
if (_topContainer is not null)
{
const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate)
_topContainer.Flush();
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
main.Clicked = () => OpenContainer(p);
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
_topContainer.AddItem(main);
}
ApplyIndicators();
RefreshBurden();
}
/// <summary>The effective open container — the explicit one, or the player (main pack) by default.
/// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default
/// sentinel is 0; once the main pack is explicitly opened, <c>_openContainer</c> holds the player
/// guid instead — both resolve here to the same main-pack container, so the paths are equivalent.</summary>
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
/// item cell → select-only.</summary>
private void AddCell(UiItemList? list, uint guid, bool isContainer)
{
if (list is null) return;
var item = _objects.Get(guid);
uint tex = item is null ? 0u
: _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
cell.SetItem(guid, tex);
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
cell.DragRejectSprite = 0x060011F8u; // red circle
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
else cell.Clicked = () => SelectItem(guid);
list.AddItem(cell);
}
private static void AddEmptyCell(UiItemList list)
{
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() };
cell.DragAcceptSprite = 0x060011F7u;
cell.DragRejectSprite = 0x060011F8u;
list.AddItem(cell);
}
/// <summary>Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay
/// (0x004e16e0): visible only for a container with itemsCapacity &gt; 0; fill =
/// GetNumContainedItems / itemsCapacity, clamped [0,1]. -1 hides the bar (non-container / unknown
/// capacity). For a CLOSED side bag the contents aren't indexed until it's opened (ViewContents),
/// so the bar reads empty until then — faithful to retail's known-children count.</summary>
private void SetCapacityBar(UiItemSlot cell, uint containerGuid)
{
int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0;
if (cap <= 0) { cell.CapacityFill = -1f; return; }
int n = _objects.GetContents(containerGuid).Count;
cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f);
}
// ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ──────────────
/// <summary>Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot
/// until the server confirms the move. Retail dims the source; we leave it + the floating ghost.</summary>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
/// <summary>Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always
/// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full.</summary>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
if (payload.ObjId == 0) return false;
if (targetList == _contentsGrid) return true;
if (targetList == _containerList || targetList == _topContainer)
{
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false;
return !IsContainerFull(targetCell.ItemId);
}
return false;
}
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
uint item = payload.ObjId;
if (item == 0) return;
uint container; int placement;
if (targetList == _contentsGrid)
{
container = EffectiveOpen();
placement = targetCell.ItemId != 0
? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot
: _objects.GetContents(container).Count; // first empty = append
}
else if (targetList == _containerList || targetList == _topContainer)
{
if (targetCell.ItemId == 0 || targetCell.ItemId == item) return;
container = targetCell.ItemId; // the bag / main pack
if (IsContainerFull(container)) return; // red already shown
placement = _objects.GetContents(container).Count; // append into it
}
else return;
if (container == item) return; // never into itself
_objects.MoveItemOptimistic(item, container, placement); // instant local move
_sendPutItemInContainer?.Invoke(item, container, placement);
}
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
private bool IsContainerFull(uint container)
{
int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
if (cap <= 0) return false;
return _objects.GetContents(container).Count >= cap;
}
/// <summary>Select an item (panel-wide green square) without changing the open container or
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
private void SelectItem(uint guid)
{
if (guid == 0) return;
_selectedItem = guid;
ApplyIndicators();
}
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and
/// NoLongerViewingContents when switching away from a previously-open side bag. Retail:
/// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary>
private void OpenContainer(uint guid)
{
if (guid == 0) return;
_selectedItem = guid; // the container cell is also the selected item
uint open = EffectiveOpen();
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
uint p = _playerGuid();
if (open != p && open != 0)
_sendNoLongerViewing?.Invoke(open); // close the previously-open side bag
_openContainer = guid;
if (guid != p)
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
Populate(); // repaint the grid for the new open container
}
/// <summary>Stamp the open-container triangle + selected-item square onto every cell per the
/// retail keying (item.itemID == openContainerId / selectedItemId).</summary>
private void ApplyIndicators()
{
SetIndicators(_contentsGrid);
SetIndicators(_containerList);
SetIndicators(_topContainer);
}
private void SetIndicators(UiItemList? list)
{
if (list is null) return;
uint open = EffectiveOpen();
for (int i = 0; i < list.GetNumUIItems(); i++)
{
var cell = list.GetItem(i);
if (cell is null) continue;
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem;
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
}
}
/// <summary>The open container's display name for the contents caption.</summary>
private string OpenContainerName()
{
uint open = EffectiveOpen();
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
}
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
{
if (host is null) return;
// The caption elements (0x100001D7 "Burden", 0x100001C5 "Contents of Backpack",
// 0x100001D8 "%") resolve to UiText (Type-0 inheriting a text base — confirmed live).
// Drive the host UiText DIRECTLY: it is already in the paint tree and renders, whereas
// a nested child UiText did not paint. Set it to a static centered single-line label.
if (host is UiText t)
{
t.Centered = true;
t.DatFont = datFont;
t.ClickThrough = true;
t.AcceptsFocus = false;
t.IsEditControl = false;
t.CapturesPointerDrag = false;
t.LinesProvider = () =>
{
var s = text();
return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, CaptionColor) };
};
return;
}
// Fallback (non-UiText host): attach a centered label child.
var label = new UiText
{
Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true, DatFont = datFont, ClickThrough = true,
AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false,
LinesProvider = () =>
{
var s = text();
return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, CaptionColor) };
},
};
host.AddChild(label);
}
/// <summary>Recompute the burden fill + percent. Port of CACQualities::InqLoad
/// (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel (0x004a6ea0). currentBurden:
/// player wire EncumbranceVal (PropertyInt 5) if present, else the carried-Burden sum.</summary>
private void RefreshBurden()
{
uint p = _playerGuid();
int str = _strength() ?? 10; // InqAttribute default 0xa
int aug = _objects.Get(p)?.Properties.GetInt(EncumbranceAugProperty) ?? 0;
int capacity = BurdenMath.EncumbranceCapacity(str, aug);
int? wire = _objects.Get(p)?.Properties.Ints.TryGetValue(EncumbranceValProperty, out var ev) == true
? ev : (int?)null;
int burden = wire ?? _objects.SumCarriedBurden(p);
float load = BurdenMath.LoadRatio(capacity, burden);
_burdenFill = BurdenMath.LoadToFill(load);
_burdenPercent = BurdenMath.LoadToPercent(load);
}
/// <summary>Detach event handlers (idempotent).</summary>
public void Dispose()
{
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
}
}

View file

@ -0,0 +1,159 @@
using System.Collections.Generic;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Resolves the empty-slot background sprite for a UIElement_ItemList's cells the way retail
/// does. Retail ref: UIElement_ItemList::InternalCreateItem (acclient_2013_pseudo_c.txt:231486 /
/// 0x004e3570): GetAttribute_Enum(this, 0x1000000e, &amp;id) -> GetByEnum(0x10000038,5,0x23) [the
/// shared UIItem catalog LayoutDesc, = 0x21000037] -> CreateChildElement(catalog, id); the cloned
/// prototype's ItemSlot_Empty (state 0x1000001c) media is the empty-cell background.
/// </summary>
public static class ItemListCellTemplate
{
/// <summary>The shared UIItem cell-template catalog. Hardcoded: retail resolves it via
/// GetByEnum(0x10000038,5,0x23) through a master enum-map DAT object (no code literal);
/// 0x21000037 is the only LayoutDesc that is a catalog of standalone UIItem (0x10000032)
/// prototypes. Validated by the real-dat test (the resolved sprite must be a real 0x06 surface).</summary>
public const uint CatalogLayoutId = 0x21000037u;
private const uint CellTemplateAttr = 0x1000000eu; // UIElement attribute: cell-template element id
private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty)
private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c
/// <summary>
/// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list
/// element <paramref name="listElementId"/> in LayoutDesc <paramref name="listLayoutId"/>.
/// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps
/// the <see cref="UiItemSlot"/> default).
/// </summary>
public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId)
{
var listLd = dats.Get<LayoutDesc>(listLayoutId);
if (listLd is null) return 0;
var listElem = FindDesc(listLd, listElementId);
if (listElem is null) return 0;
uint protoId = ReadCellTemplateId(listElem); // attr 0x1000000e
if (protoId == 0) return 0;
var catalog = dats.Get<LayoutDesc>(CatalogLayoutId);
if (catalog is null) return 0;
var proto = FindDesc(catalog, protoId);
if (proto is null) return 0;
// The empty-slot BACKGROUND is the m_elem_Icon's ItemSlot_Empty media, resolved THROUGH
// BaseElement inheritance: the 36x36 container prototype (0x1000033F) nests its icon behind
// an inner wrapper (0x10000340 -> base 0x1000033E -> 0x1000033B -> 0x06000F6E), so a raw
// child search alone returns nothing for containers. We deliberately do NOT use the
// prototype's DirectState child overlay: on the container prototype that child is the
// open/selected-container TRIANGLE indicator (0x06005D9C), which retail draws ONLY on the
// selected container (a deferred container-selection feature) — never as empty-cell art.
// (Live visual gate 2026-06-22: frame-first stamped the triangle onto every empty cell.)
return FindIconEmpty(catalog, proto, new HashSet<uint>());
}
// ── attribute 0x1000000e: stored as EnumBaseProperty in the dat (Value is the prototype id) ──
// Note: the spec anticipated DataIdBaseProperty/ArrayBaseProperty based on the font-DID pattern,
// but the live dat uses EnumBaseProperty.Value (uint) — confirmed by runtime reflection.
private static uint ReadCellTemplateId(ElementDesc elem)
{
uint id = ReadIdFromState(elem.StateDesc);
if (id != 0) return id;
foreach (var s in elem.States)
{
id = ReadIdFromState(s.Value);
if (id != 0) return id;
}
return 0;
}
private static uint ReadIdFromState(StateDesc? sd)
{
if (sd?.Properties is null) return 0;
return sd.Properties.TryGetValue(CellTemplateAttr, out var raw) ? ReadId(raw) : 0;
}
private static uint ReadId(BaseProperty raw)
{
if (raw is ArrayBaseProperty arr && arr.Value.Count > 0) return ReadId(arr.Value[0]);
if (raw is DataIdBaseProperty did) return did.Value;
if (raw is EnumBaseProperty ep) return ep.Value; // 0x1000000e is EnumBaseProperty in the live dat
return 0;
}
// ── prototype media: the m_elem_Icon (0x1000033B) ItemSlot_Empty, resolved through inheritance ──
// Find the icon child's empty media within `element`'s subtree, following BaseElement edges
// within the same catalog (cycle-guarded by `baseSeen`). The 32x32 contents prototype carries
// 0x1000033B as a direct child; the 36x36 container prototype reaches it only via an inherited
// base (0x10000340 -> 0x1000033E), so a raw child search alone returns nothing for containers.
private static uint FindIconEmpty(LayoutDesc catalog, ElementDesc element, HashSet<uint> baseSeen)
{
if (element.ElementId == IconChildId)
{
uint e = IconEmptyState(element);
if (e != 0) return e;
}
foreach (var kv in element.Children)
{
uint e = FindIconEmpty(catalog, kv.Value, baseSeen);
if (e != 0) return e;
}
if (element.BaseElement != 0 && element.BaseLayoutId == CatalogLayoutId && baseSeen.Add(element.BaseElement))
{
var baseDesc = FindDesc(catalog, element.BaseElement);
if (baseDesc is not null)
{
uint e = FindIconEmpty(catalog, baseDesc, baseSeen);
if (e != 0) return e;
}
}
return 0;
}
private static uint IconEmptyState(ElementDesc icon)
{
foreach (var s in icon.States)
if (s.Key.ToString() == ItemSlotEmpty)
{
uint f = StateImage(s.Value);
if (f != 0) return f;
}
return DirectStateImage(icon); // some icons carry empty media on the DirectState
}
private static uint DirectStateImage(ElementDesc d)
=> d.StateDesc is null ? 0u : StateImage(d.StateDesc);
private static uint StateImage(StateDesc sd)
{
foreach (var m in sd.Media)
if (m is MediaDescImage img && img.File != 0) return img.File;
return 0;
}
// ── depth-first element search by id (LayoutImporter.FindDesc is private there) ──
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
{
foreach (var kv in ld.Elements)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
{
if (d.ElementId == id) return d;
foreach (var kv in d.Children)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
}

View file

@ -191,6 +191,14 @@ public static class LayoutImporter
// ── Inheritance resolution ────────────────────────────────────────────────
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
/// (close button / title), elements with their own children, and childless style
/// prototypes (vitals/chat/toolbar text — the base prototype has no children).</summary>
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
/// <summary>
/// Converts an <see cref="ElementDesc"/> to a resolved <see cref="ElementInfo"/>:
/// reads own fields + media, applies the BaseElement / BaseLayoutId chain
@ -204,6 +212,7 @@ public static class LayoutImporter
// Read this element's own fields + media (no inheritance, no children yet).
var self = ToInfo(d);
var result = self;
List<ElementInfo>? baseChildren = null;
// Apply BaseElement / BaseLayoutId inheritance if present.
if (d.BaseElement != 0 && d.BaseLayoutId != 0
@ -215,9 +224,9 @@ public static class LayoutImporter
{
// Recurse the base chain (already guarded by the HashSet add above).
var baseInfo = Resolve(dats, baseDesc, baseChain);
// Derived fields override the base; result.Children is still empty here
// — children are attached below from the DERIVED element's own tree.
// Derived fields override the base; children are attached below.
result = ElementReader.Merge(baseInfo, self);
baseChildren = baseInfo.Children; // capture for the sub-window mount
}
}
@ -226,6 +235,28 @@ public static class LayoutImporter
foreach (var kv in d.Children)
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
// inert for media-bearing inheritors (close button/title) and childless style prototypes
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
if (baseChildren is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
{
result.Children.AddRange(baseChildren);
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
// ZLevel 0) inherit that 1000, and the #145 ZOrder fold (ReadOrder ZLevel·10000)
// turns 1000 into ZOrder ≈ 10,000,000 — sinking the whole panel BEHIND the frame's
// Alphablend backdrop (ZLevel 100 → ≈ 1,000,000). The backdrop then overpaints the
// panel's captions/meter/cells (the wash-out bug; the paperdoll root happens to be
// ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
result.ZLevel = self.ZLevel;
}
return result;
}
@ -252,6 +283,7 @@ public static class LayoutImporter
Right = d.RightEdge,
Bottom = d.BottomEdge,
ReadOrder = d.ReadOrder,
ZLevel = d.ZLevel,
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
};

View file

@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.Core.Items;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the ~21 equip slots mounted under the paperdoll (gmPaperDollUI 0x21000024, nested in the
/// inventory frame 0x21000023) to live equipped-item data and makes them drag-drop WIELD targets.
/// The acdream analogue of gmPaperDollUI::PostInit + GetLocationInfoFromElementID (named-retail decomp
/// 175480 / 173620). Slice 1: equip slots only — no 3D doll viewport (that's Slice 2).
/// Unwield is handled by InventoryController (dragging an equipped item onto the pack grid).
/// </summary>
public sealed class PaperdollController : IItemListDragHandler
{
// WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon.
private const EquipMask WeaponSlotMask =
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded;
// element-id → EquipMask (verified: dump paperdoll-0x21000024.txt ↔ deep-dive §3a ↔ acclient.h:3193).
// The 3 Aetheria sigil slots (0x10000595/96/97) are SetVisible(0) in retail — skipped (Slice 1 scope).
private static readonly (uint Element, EquipMask Mask)[] SlotMap =
{
(0x100005ABu, EquipMask.HeadWear), // 0x1
(0x100001E2u, EquipMask.ChestWear), // 0x2
(0x100001E3u, EquipMask.UpperLegWear), // 0x40
(0x100005B0u, EquipMask.HandWear), // 0x20
(0x100005B3u, EquipMask.FootWear), // 0x100
(0x100005ACu, EquipMask.ChestArmor), // 0x200
(0x100005ADu, EquipMask.AbdomenArmor), // 0x400
(0x100005AEu, EquipMask.UpperArmArmor), // 0x800
(0x100005AFu, EquipMask.LowerArmArmor), // 0x1000
(0x100005B1u, EquipMask.UpperLegArmor), // 0x2000
(0x100005B2u, EquipMask.LowerLegArmor), // 0x4000
(0x100001DAu, EquipMask.NeckWear), // 0x8000
(0x100001DBu, EquipMask.WristWearLeft), // 0x10000
(0x100001DDu, EquipMask.WristWearRight), // 0x20000
(0x100001DCu, EquipMask.FingerWearLeft), // 0x40000
(0x100001DEu, EquipMask.FingerWearRight), // 0x80000
(0x100001E1u, EquipMask.Shield), // 0x200000
(0x100001E0u, EquipMask.MissileAmmo), // 0x800000 (LIKELY — gate-verify, AP row)
(0x100001DFu, WeaponSlotMask), // 0x3500000 composite
(0x1000058Eu, EquipMask.TrinketOne), // 0x4000000
(0x100005E9u, EquipMask.Cloak), // 0x8000000
};
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
private PaperdollController(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
uint emptySlotSprite)
{
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
for (int i = 0; i < SlotMap.Length; i++)
{
var (element, mask) = SlotMap[i];
if (layout.FindElement(element) is not UiItemList list) continue;
list.RegisterDragHandler(this);
list.Cell.SourceKind = ItemDragSource.Equipment;
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
// 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot
// silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle.
// Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring
// 0x060011F9 / reject circle 0x060011F8 — the discrete-slot frames, NOT the inventory grid's
// insert-arrow 0x060011F7) are already wired by DatWidgetFactory when it built the UiItemList;
// no need to re-set them here.
_slots.Add((mask, list));
}
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
Populate();
}
public static PaperdollController Bind(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
uint emptySlotSprite = 0u)
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
/// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always
/// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by
/// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved adds the from/to
/// player backstop for moves that transiently satisfy neither (e.g. unwield into a side bag).</summary>
private bool Concerns(ClientObject o)
{
uint p = _playerGuid();
return o.WielderId == p || o.ContainerId == p;
}
public void Populate()
{
uint p = _playerGuid();
// The player's equipped gear (one pass). Scoped to the player so a vendor's/NPC's wielded item
// (which can also carry CurrentWieldedLocation) can't leak into the doll.
var equipped = new List<ClientObject>();
foreach (var o in _objects.Objects)
if (o.CurrentlyEquippedLocation != EquipMask.None && (o.WielderId == p || o.ContainerId == p))
equipped.Add(o);
foreach (var (mask, list) in _slots)
{
ClientObject? worn = null;
foreach (var o in equipped)
if ((o.CurrentlyEquippedLocation & mask) != EquipMask.None) { worn = o; break; }
if (worn is null) { list.Cell.Clear(); continue; }
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
list.Cell.SetItem(worn.ObjectId, tex);
}
}
private EquipMask MaskFor(UiItemList list)
{
foreach (var (mask, l) in _slots) if (ReferenceEquals(l, list)) return mask;
return EquipMask.None;
}
// ── IItemListDragHandler ──────────────────────────────────────────────────────────────────────
/// <summary>No-op: a wielded item stays put until the server confirms (same as the inventory grid,
/// unlike the toolbar's remove-on-lift). Unwield happens on DROP onto the pack grid — InventoryController.</summary>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
/// <summary>Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask).
/// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302).</summary>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
var item = _objects.Get(payload.ObjId);
if (item is null) return false;
return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None;
}
/// <summary>Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A.
/// wieldMask = ValidLocations & slotMask resolves the specific bit (the composite weapon slot, the
/// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
var item = _objects.Get(payload.ObjId);
if (item is null) return;
EquipMask wieldMask = item.ValidLocations & MaskFor(targetList);
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);
}
/// <summary>Detach event handlers (idempotent).</summary>
public void Dispose()
{
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
}
}

View file

@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout;
/// <c>CreateObject</c> resolves a formerly-unknown guid.
/// </para>
/// </summary>
public sealed class ToolbarController
public sealed class ToolbarController : IItemListDragHandler
{
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
@ -60,6 +60,10 @@ public sealed class ToolbarController
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
private readonly Action<uint> _useItem; // guid → fire UseObject
private readonly AcDream.Core.Items.ShortcutStore _store = new();
private bool _storeLoaded;
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
private readonly Action<uint>? _sendRemoveShortcut; // (index)
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
@ -82,7 +86,9 @@ public sealed class ToolbarController
CombatState? combatState,
uint[]? peaceDigits,
uint[]? warDigits,
uint[]? emptyDigits)
uint[]? emptyDigits,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
_repo = repo;
_shortcuts = shortcuts;
@ -91,12 +97,23 @@ public sealed class ToolbarController
_peaceDigits = peaceDigits;
_warDigits = warDigits;
_emptyDigits = emptyDigits;
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
for (int i = 0; i < SlotIds.Length; i++)
{
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
if (_slots[i] is { } list)
{
WireClick(list);
// B.1 drag-drop spine: this controller is the drop handler for every
// toolbar slot list; each cell knows its slot index + that it's a
// shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
list.RegisterDragHandler(this);
list.Cell.SlotIndex = i;
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
}
}
// Cache the four mutually-exclusive combat-mode indicator elements.
@ -125,11 +142,20 @@ public sealed class ToolbarController
}
/// <summary>
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids.
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids
/// (i.e., present in the live <see cref="AcDream.Core.Items.ShortcutStore"/>).
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
/// Falls back to scanning <c>_shortcuts()</c> before the store is loaded (pre-PD window).
/// </summary>
private bool IsShortcutGuid(uint guid)
{
if (_storeLoaded)
{
for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++)
if (_store.Get(s) == guid) return true;
return false;
}
// Store not yet loaded — fall back to the shortcuts provider (pre-PD window).
foreach (var sc in _shortcuts())
if (sc.ObjectGuid == guid) return true;
return false;
@ -174,10 +200,13 @@ public sealed class ToolbarController
CombatState? combatState = null,
uint[]? peaceDigits = null,
uint[]? warDigits = null,
uint[]? emptyDigits = null)
uint[]? emptyDigits = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits);
peaceDigits, warDigits, emptyDigits,
sendAddShortcut, sendRemoveShortcut);
c.Populate();
return c;
}
@ -188,24 +217,31 @@ public sealed class ToolbarController
/// Entries whose item is not yet in the repo are silently skipped here; the
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
/// As of B.2: the <see cref="AcDream.Core.Items.ShortcutStore"/> is the
/// authoritative in-session slot map; <c>_shortcuts()</c> seeds it on first call.
/// </summary>
public void Populate()
{
// Clear all slot cells first (flush).
// Lazy-load the store from the login shortcut list the first time it's present (PD arrives
// after Bind); thereafter the store is authoritative and drag ops mutate it directly.
if (!_storeLoaded && _shortcuts().Count > 0)
{
_store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid)));
_storeLoaded = true;
}
foreach (var list in _slots) list?.Cell.Clear();
foreach (var sc in _shortcuts())
for (int slot = 0; slot < _slots.Length; slot++)
{
if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase
if (sc.Index >= (uint)_slots.Length) continue;
var list = _slots[(int)sc.Index];
uint guid = _store.Get(slot);
if (guid == 0) continue;
var list = _slots[slot];
if (list is null) continue;
var item = _repo.Get(sc.ObjectGuid);
if (item is null) continue; // deferred: ObjectAdded will re-call Populate
var item = _repo.Get(guid);
if (item is null) continue; // deferred: ObjectAdded re-calls Populate
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
list.Cell.SetItem(sc.ObjectGuid, tex);
list.Cell.SetItem(guid, tex);
}
// Re-stamp slot number labels after any item change.
@ -287,4 +323,46 @@ public sealed class ToolbarController
_useItem(list.Cell.ItemId);
};
}
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// Retail model (remove-on-lift / place-on-drop / no-restore):
// lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately)
// drop → AddShortcut (0x019C) + optional swap of evicted item into source
// off-bar release → the lift's removal stands (no restore)
// Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971
/// <inheritdoc/>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
{
// Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted
// shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a
// drop onto a slot. Off-bar release leaves it removed.
_store.Remove(payload.SourceSlot);
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
Populate();
}
/// <inheritdoc/>
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.ObjId != 0;
/// <inheritdoc/>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5):
// evict the target's occupant, place the dragged item there, and bump the evicted item into
// the (now-vacated) source slot if it's free.
int target = targetCell.SlotIndex;
uint evicted = _store.Get(target); // RemoveShortCutInSlotNum(target)
if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); }
_store.Set(target, payload.ObjId); // AddShortcut(dragged, target)
_sendAddShortcut?.Invoke((uint)target, payload.ObjId);
if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot))
{ // displaced → vacated source slot
_store.Set(payload.SourceSlot, evicted);
_sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted);
}
Populate();
}
}

View file

@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI;
/// <summary>
/// A toolbar-frame variant that snaps its height between two stops — collapsed (row 2 hidden) and
/// expanded (row 2 shown) — and toggles a set of "second-row" elements to match. Resized via the
/// bottom edge (the mount sets <see cref="UiElement.ResizableEdges"/> = Bottom); each tick it resolves
/// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a
/// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17.
/// </summary>
public sealed class UiCollapsibleFrame : UiNineSlicePanel
{
public UiCollapsibleFrame(Func<uint, (uint, int, int)> resolve) : base(resolve) { }
public float CollapsedHeight { get; set; }
public float ExpandedHeight { get; set; }
/// <summary>Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed.</summary>
public IReadOnlyList<UiElement> SecondRow { get; set; } = Array.Empty<UiElement>();
/// <summary>True when the frame is at (or nearer) the expanded stop.</summary>
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
protected override void OnTick(double deltaSeconds)
{
base.OnTick(deltaSeconds);
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet — no snap
bool expanded = IsExpanded;
Height = expanded ? ExpandedHeight : CollapsedHeight; // snap the dragged height to a stop
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
}
/// <summary>Test hook — OnTick is protected. Drives one snap+visibility reconcile.</summary>
internal void TickForTest(double dt) => OnTick(dt);
}

View file

@ -113,15 +113,31 @@ public abstract class UiElement
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
public bool CapturesPointerDrag { get; set; }
/// <summary>If true, a left-press-and-move on this element starts a DRAG-DROP
/// (<see cref="UiRoot"/> promotes to BeginDrag) rather than moving a Draggable
/// ancestor window — so an item cell inside the toolbar frame drags the item, not
/// the window. Distinct from <see cref="CapturesPointerDrag"/> (a self-driven
/// interior drag like text selection, which does NOT promote to BeginDrag). Default
/// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary>
public virtual bool IsDragSource => false;
/// <summary>Minimum size enforced while resizing.</summary>
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
public float MaxHeight { get; set; } = float.MaxValue;
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeX { get; set; } = true;
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeY { get; set; } = true;
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
/// Set to e.g. <see cref="ResizeEdges.Bottom"/> to allow only a bottom-edge drag (the collapse toolbar).</summary>
public ResizeEdges ResizableEdges { get; set; } =
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
/// <summary>Edges this element anchors to in its parent. Default Left|Top
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
@ -203,6 +219,16 @@ public abstract class UiElement
/// </summary>
public virtual bool OnEvent(in UiEvent e) => false;
/// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.</summary>
public virtual object? GetDragPayload() => null;
/// <summary>The texture <see cref="UiRoot"/> paints at the cursor while this element
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
/// <summary>
/// Tooltip text for this widget. Retail fires event 0x07 after
/// ~1000ms hover, then queries the widget's virtual "GetString"

View file

@ -103,6 +103,14 @@ public sealed class UiHost : System.IDisposable
_ => UiMouseButton.Left,
};
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
/// <summary>Register a top-level window for Show/Hide/Toggle. See <see cref="UiRoot.RegisterWindow"/>.</summary>
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
/// <summary>Toggle a registered window's visibility; returns the new IsVisible.</summary>
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
public void Dispose()
{
TextRenderer.Dispose();

View file

@ -13,6 +13,10 @@ public sealed class UiItemList : UiElement
{
private readonly List<UiItemSlot> _cells = new();
/// <summary>Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
/// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).</summary>
public UiScrollable Scroll { get; } = new();
public UiItemList(Func<uint, (uint tex, int w, int h)>? spriteResolve = null)
{
SpriteResolve = spriteResolve;
@ -24,6 +28,31 @@ public sealed class UiItemList : UiElement
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
private uint _cellEmptySprite;
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
/// <see cref="Layout.ItemListCellTemplate.ResolveEmptySprite"/>). 0 = leave the
/// <see cref="UiItemSlot.EmptySprite"/> default (0x060074CF). Applied to every existing
/// AND future cell.</summary>
public uint CellEmptySprite
{
get => _cellEmptySprite;
set
{
_cellEmptySprite = value;
if (value != 0)
foreach (var c in _cells) c.EmptySprite = value;
}
}
/// <summary>The drag handler this list routes drops to (a panel controller).
/// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler.</summary>
public IItemListDragHandler? DragHandler { get; private set; }
/// <summary>Register the panel's drag handler on this list. Retail:
/// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461).</summary>
public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler;
/// <summary>Convenience for single-cell slots (the toolbar): the first cell.
/// Valid only while the list has at least one cell; after <see cref="Flush"/>
/// (the inventory-phase rebuild path) the list is empty until <see cref="AddItem"/>
@ -41,9 +70,81 @@ public sealed class UiItemList : UiElement
public void AddItem(UiItemSlot cell)
{
cell.SpriteResolve ??= SpriteResolve;
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
// The list lays cells out procedurally (grid offset + scroll clip), so cells must be
// EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor
// on every child after OnDraw, which would otherwise reset the scroll offset to each
// cell's captured base position (the escaping-grid bug). Anchors=None makes LayoutCells
// the sole authority over cell rects.
cell.Anchors = AnchorEdges.None;
_cells.Add(cell);
AddChild(cell);
LayoutCells();
}
/// <summary>Grid columns (row-major). 1 = single column. Ignored in fill mode.</summary>
public int Columns { get; set; } = 1;
/// <summary>Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
/// to the whole list (the toolbar single-slot legacy). Set &gt;0 (with CellHeight) for a grid.</summary>
public float CellWidth { get; set; }
/// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
public float CellHeight { get; set; }
/// <summary>Whole rows needed for <paramref name="cellCount"/> cells in a grid of
/// <paramref name="columns"/> columns.</summary>
public static int RowCount(int cellCount, int columns)
{
int cols = columns < 1 ? 1 : columns;
return (cellCount + cols - 1) / cols;
}
/// <summary>Row-major pixel offset of cell <paramref name="index"/> in a grid of
/// <paramref name="columns"/> columns at the given cell pitch.</summary>
internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH)
{
int col = index % columns, row = index / columns;
return (col * cellW, row * cellH);
}
/// <summary>Position every cell per the current mode: fill (CellWidth&lt;=0) sizes the single
/// cell to the list; grid (CellWidth&gt;0) tiles cells row-major, offset by the scroll position
/// with whole-row vertical clipping (cells fully outside the view are hidden).</summary>
internal void LayoutCells()
{
if (CellWidth <= 0f)
{
// Fill mode (the toolbar single cell): size the one cell to the list.
if (_cells.Count > 0)
{
var c = _cells[0];
c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; c.Visible = true;
}
return;
}
int cols = Columns < 1 ? 1 : Columns;
int cellH = (int)MathF.Round(CellHeight);
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
// the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY
// so the clamp uses the right max.
Scroll.LineHeight = cellH > 0 ? cellH : 1;
Scroll.ContentHeight = RowCount(_cells.Count, cols) * cellH;
Scroll.ViewHeight = (int)MathF.Floor(Height);
Scroll.SetScrollY(Scroll.ScrollY); // re-clamp to the new max
float scrollY = Scroll.ScrollY;
for (int i = 0; i < _cells.Count; i++)
{
var (x, baseY) = CellOffset(i, cols, CellWidth, CellHeight);
float top = baseY - scrollY;
var cell = _cells[i];
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
// Whole-row vertical clip (no scissor — mirrors UiText.cs:198). A row fully inside
// [0, Height] draws; a partially-scrolled row is hidden.
cell.Visible = top >= -0.5f && top + CellHeight <= Height + 0.5f;
}
}
public void Flush()
@ -52,16 +153,21 @@ public sealed class UiItemList : UiElement
_cells.Clear();
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Scroll && CellWidth > 0f)
{
// Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0.
Scroll.ScrollByLines(-e.Data0);
return true;
}
return base.OnEvent(e);
}
protected override void OnDraw(UiRenderContext ctx)
{
// The factory sets THIS list's Width/Height AFTER construction, so the cell
// (added in the ctor) starts 0x0. For the single-cell toolbar slot, keep the
// cell sized to the list each frame; the cell paints itself in the children
// pass that follows. (N-cell grid layout is the inventory phase.)
if (_cells.Count > 0)
{
var cell = _cells[0];
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
}
// The factory sets Width/Height AFTER construction, so re-layout each frame:
// fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
LayoutCells();
}
}

View file

@ -21,6 +21,56 @@ public sealed class UiItemSlot : UiElement
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
public uint IconTexture { get; private set; }
/// <summary>This cell's own index within its panel (0..17 toolbar; container slot
/// for inventory). Distinct from <see cref="ShortcutNum"/> (the 19 label, -1 on the
/// bottom row). Set by the controller; used as the drag payload's SourceSlot and to
/// identify the drop TARGET slot.</summary>
public int SlotIndex { get; set; } = -1;
/// <summary>What kind of slot this is, for the drag payload (retail InqDropIconInfo
/// flags). Controller overrides; default Inventory.</summary>
public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;
/// <summary>Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9,
/// state id 0x10000041). Configurable; guard id != 0 before resolving.</summary>
public uint DragAcceptSprite { get; set; } = 0x060011F9u;
/// <summary>Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8,
/// state id 0x10000040).</summary>
public uint DragRejectSprite { get; set; } = 0x060011F8u;
/// <summary>True when this cell is the OPEN container (its contents fill the grid). Draws the
/// open-container triangle. Port of UIElement_ItemList::UpdateOpenContainerIndicator
/// (0x004e3070) → SetOpenContainerState (0x004e1200): shown when item.itemID == openContainerId.</summary>
public bool IsOpenContainer { get; set; }
/// <summary>Open-container triangle sprite (element 0x10000450 on the container prototype
/// 0x1000033F). Configurable; guard id != 0 before resolving.</summary>
public uint OpenContainerSprite { get; set; } = 0x06005D9Cu;
/// <summary>True when this cell is the SELECTED item. Draws the green/yellow selection square.
/// Port of UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0) → SetSelectedState
/// (0x004e1240): shown when item.itemID == selectedItemId. Uniform across item + container cells.</summary>
public bool Selected { get; set; }
/// <summary>Selected-item square sprite (element 0x10000342 on the 32×32 item prototype
/// 0x10000341; pixel-confirmed green/yellow frame). Drawn as a procedural overlay so it renders
/// on the 36×36 container cell too (whose prototype lacks the square child).</summary>
public uint SelectedSprite { get; set; } = 0x06004D21u;
/// <summary>Container fullness [0..1], or -1 = hidden (the cell is not a container, or its
/// itemsCapacity is unknown/0). Port of UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a
/// per-cell vertical UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only when
/// isContainer &amp;&amp; itemsCapacity &gt; 0, fill = numContainedItems / itemsCapacity (meter attr 0x69).</summary>
public float CapacityFill { get; set; } = -1f;
/// <summary>Capacity-bar track sprite (meter 0x10000347 DirectState).</summary>
public uint CapacityBackSprite { get; set; } = 0x06004D22u;
/// <summary>Capacity-bar fill sprite (meter 0x10000347 front child 0x00000002 DirectState).</summary>
public uint CapacityFrontSprite { get; set; } = 0x06004D23u;
/// <summary>Accept/reject overlay state while a drag hovers this cell.</summary>
public enum DragAcceptState { None, Accept, Reject }
private DragAcceptState _dragAccept = DragAcceptState.None;
/// <summary>Current overlay state — internal so unit tests can assert it (InternalsVisibleTo).</summary>
internal DragAcceptState DragAcceptVisual => _dragAccept;
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
@ -37,6 +87,30 @@ public sealed class UiItemSlot : UiElement
public void Clear() { ItemId = 0; IconTexture = 0; }
/// <inheritdoc/>
public override object? GetDragPayload()
=> ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null;
/// <inheritdoc/>
public override (uint tex, int w, int h)? GetDragGhost()
=> ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
/// <summary>An OCCUPIED slot is a drag source — a press-and-move picks up the item
/// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a
/// press-and-move there falls through to the IA-12 whole-window-drag, keeping the bar
/// movable by its empty cells / chrome. Drives <see cref="UiRoot"/>'s mousedown
/// window-vs-item disambiguation (retail moves the window via a dragbar, never cells;
/// our whole-window-drag approximation reconciles by gating on occupancy).</summary>
public override bool IsDragSource => ItemId != 0;
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
private UiItemList? FindList()
{
UiElement? e = Parent;
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
return null;
}
// ── Shortcut number (slot label) ─────────────────────────────────────────
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
// Retail draws the digit on the cell's ShortcutNum sub-element, picking the
@ -100,7 +174,40 @@ public sealed class UiItemSlot : UiElement
/// <inheritdoc/>
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
switch (e.Type)
{
// Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a
// drag (press + >3px move) does NOT also use the item. UiRoot suppresses the
// post-drag Click (UiRoot.cs FinishDrag returns before the Click emit).
case UiEventType.MouseDown:
return true; // consume the press; no use here
case UiEventType.Click:
Clicked?.Invoke();
return true;
case UiEventType.DragBegin:
// Notify the source list's handler so it can lift (remove + wire) — retail
// RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first.
if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp)
lh.OnDragLift(liftList, this, lp);
return true;
case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler
_dragAccept = (FindList() is { DragHandler: { } h } list
&& e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p))
? DragAcceptState.Accept : DragAcceptState.Reject;
return true;
case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral
_dragAccept = DragAcceptState.None;
return true;
case UiEventType.DropReleased:
_dragAccept = DragAcceptState.None;
if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp)
dh.HandleDropRelease(dl, this, dp);
return true;
}
return false;
}
@ -139,5 +246,63 @@ public sealed class UiItemSlot : UiElement
}
}
}
// Container capacity bar — UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a vertical
// UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only for container cells
// (CapacityFill >= 0). Track drawn full; fill clipped to the fraction from the BOTTOM (the
// "how full" direction). Procedural — UiItemSlot is a behavioral leaf. Guard id != 0 first.
if (CapacityFill >= 0f && SpriteResolve is not null)
{
const float by = 1f, bw = 5f, bh = 30f; // element 0x10000347 size (dat 5×30 at y=1)
float bx = Width - bw; // flush to the cell's right edge (visual gate: the dat X=26 sat ~5px off the edge)
if (CapacityBackSprite != 0)
{
var (bt, _, _) = SpriteResolve(CapacityBackSprite);
if (bt != 0) ctx.DrawSprite(bt, bx, by, bw, bh, 0f, 0f, 1f, 1f, Vector4.One);
}
float f = Math.Clamp(CapacityFill, 0f, 1f);
if (f > 0f && CapacityFrontSprite != 0)
{
var (ft, _, _) = SpriteResolve(CapacityFrontSprite);
if (ft != 0)
{
// Bottom-up fill: draw the bottom f-fraction of the bar, sampling the matching
// bottom slice of the front sprite (UV v from 1-f to 1).
float fh = bh * f;
ctx.DrawSprite(ft, bx, by + (bh - fh), bw, fh, 0f, 1f - f, 1f, 1f, Vector4.One);
}
}
}
// Open-container triangle (retail SetOpenContainerState 0x004e1200) + selected-item square
// (retail SetSelectedState 0x004e1240). Drawn procedurally like the digit/drag overlays;
// guard id != 0 BEFORE resolving (feedback_ui_resolve_zero_magenta). Triangle under the
// square; both under the transient drag overlay below.
if (IsOpenContainer && SpriteResolve is not null && OpenContainerSprite != 0)
{
var (tex, _, _) = SpriteResolve(OpenContainerSprite);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
if (Selected && SpriteResolve is not null && SelectedSprite != 0)
{
var (tex, _, _) = SpriteResolve(SelectedSprite);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
// Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40).
// Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder
// with a non-zero GL handle (feedback_ui_resolve_zero_magenta).
if (_dragAccept != DragAcceptState.None && SpriteResolve is not null)
{
uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite;
if (id != 0)
{
var (tex, _, _) = SpriteResolve(id);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
}
}

View file

@ -55,6 +55,14 @@ public sealed class UiMeter : UiElement
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
public uint FrontRight { get; set; }
/// <summary>Vertical orientation (retail <c>m_eDirection</c> 2/4). Default false =
/// horizontal (direction 1). The burden bar is the only vertical meter today.</summary>
public bool Vertical { get; set; }
/// <summary>For a vertical meter, fill grows from the bottom up (retail direction 4)
/// when true, top-down (direction 2) when false. Default bottom-up. Visual-confirmed.</summary>
public bool FillFromBottom { get; set; } = true;
public UiMeter() { ClickThrough = true; }
/// <summary>The meter draws its own 3-slice bars; the importer must not build its
@ -71,6 +79,18 @@ public sealed class UiMeter : UiElement
return (0f, 0f, w * pct, h);
}
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the vertical fill rect
/// (local px). <paramref name="fromBottom"/> true → the fill occupies the bottom
/// <c>h*pct</c> px (retail direction 4); false → the top (direction 2).</summary>
public static (float x, float y, float w, float h) ComputeVFillRect(
float pct, float w, float h, bool fromBottom)
{
if (pct < 0f) pct = 0f;
if (pct > 1f) pct = 1f;
float fh = h * pct;
return (0f, fromBottom ? h - fh : 0f, w, fh);
}
protected override void OnDraw(UiRenderContext ctx)
{
float? pct = Fill();
@ -78,14 +98,25 @@ public sealed class UiMeter : UiElement
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
{
// Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
// empty track, drawn full width; the FRONT 3-slice is the coloured fill,
// drawn at FULL width too but horizontally CLIPPED to the fill fraction.
// The front carries its own right-cap (shown at 100%); clipping below 100%
// removes it and reveals the back track's right-cap — retail's scissor-fill.
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
if (pct is not null && p > 0f)
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
if (Vertical)
{
// Track full height, fill clipped to the fraction along the fill direction.
// The burden meter is a single-tile bar (BackTile/FrontTile, no caps).
DrawVBar(ctx, resolve, BackTile, Height, fromBottom: FillFromBottom, isFill: false);
if (pct is not null && p > 0f)
DrawVBar(ctx, resolve, FrontTile, Height * p, fromBottom: FillFromBottom, isFill: true);
}
else
{
// Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
// empty track, drawn full width; the FRONT 3-slice is the coloured fill,
// drawn at FULL width too but horizontally CLIPPED to the fill fraction.
// The front carries its own right-cap (shown at 100%); clipping below 100%
// removes it and reveals the back track's right-cap — retail's scissor-fill.
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
if (pct is not null && p > 0f)
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
}
}
else
{
@ -158,6 +189,28 @@ public sealed class UiMeter : UiElement
DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW);
}
/// <summary>Draws a single-tile vertical bar slice. The track (<paramref name="isFill"/>
/// false) spans the full height; the fill spans <paramref name="visibleH"/> px from the
/// bottom (<paramref name="fromBottom"/>) or top, UV-cropped to that fraction of the
/// sprite so the fill reveals the matching part of the art (retail
/// UIElement_Meter::DrawChildren Box2D clip, direction 2/4). A 0 id is a no-op.</summary>
private void DrawVBar(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint tileId, float visibleH, bool fromBottom, bool isFill)
{
if (tileId == 0 || visibleH <= 0f) return;
var (tex, _, _) = resolve(tileId);
if (tex == 0) return;
float w = Width, h = Height;
if (visibleH > h) visibleH = h;
float frac = h > 0f ? visibleH / h : 0f;
// Bottom-up fill: bottom of the rect AND bottom of the sprite (v in [1-frac, 1]).
// Top-down / track: top of the rect AND top of the sprite (v in [0, frac]).
float y = isFill && fromBottom ? h - visibleH : 0f;
float v0 = isFill && fromBottom ? 1f - frac : 0f;
float v1 = isFill && fromBottom ? 1f : frac;
ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One);
}
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).

View file

@ -10,7 +10,7 @@ namespace AcDream.App.UI;
/// the widget is testable without GL. In production:
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
/// </summary>
public sealed class UiNineSlicePanel : UiPanel
public class UiNineSlicePanel : UiPanel
{
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
public readonly record struct Rect(float X, float Y, float W, float H);

View file

@ -71,6 +71,9 @@ public sealed class UiRoot : UiElement
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
private (uint tex, int w, int h)? _dragGhost;
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
private UiElement? _lastDragHoverTarget;
private int _pressX, _pressY;
private bool _dragCandidate;
@ -141,9 +144,23 @@ public sealed class UiRoot : UiElement
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
ctx.BeginOverlayLayer();
DrawOverlays(ctx);
DrawDragGhost(ctx);
ctx.EndOverlayLayer();
}
private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade
/// <summary>Paint the drag ghost at the cursor. The texture comes from the snapshotted
/// ghost captured in <see cref="BeginDrag"/> so UiRoot stays item-agnostic and the ghost
/// survives the source cell emptying on lift; the ghost is NOT a tree element, so it
/// never intercepts hit-tests.</summary>
private void DrawDragGhost(UiRenderContext ctx)
{
if (_dragGhost is not { } g || g.tex == 0) return;
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
}
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
public void OnMouseMove(int x, int y)
@ -159,7 +176,8 @@ public sealed class UiRoot : UiElement
var (nx, ny, nw, nh) = ResizeRect(
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
_resizeTarget.MinWidth, _resizeTarget.MinHeight);
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
float.MaxValue, _resizeTarget.MaxHeight);
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
return;
@ -185,7 +203,7 @@ public sealed class UiRoot : UiElement
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
{
BeginDrag(Captured, payload: null);
BeginDrag(Captured);
}
}
if (DragSource is not null)
@ -230,6 +248,8 @@ public sealed class UiRoot : UiElement
// A left-drag starting near an edge resizes; interior drag repositions;
// otherwise it's a normal drag-drop candidate.
var window = FindWindow(target);
// Retail-faithful: pressing on a window raises it above its peers.
if (window is not null) BringToFront(window);
if (btn == UiMouseButton.Left && window is not null)
{
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
@ -244,6 +264,17 @@ public sealed class UiRoot : UiElement
_resizeMouseX = x; _resizeMouseY = y;
_dragCandidate = false;
}
else if (target.IsDragSource)
{
// A drag SOURCE (e.g. an occupied item cell) inside a Draggable window
// starts an item drag-drop, NOT a window move. UiRoot stays item-agnostic:
// it only reads the IsDragSource flag (the cell decides occupancy). The
// BeginDrag promotion happens on the >3px move (and cancels if the source's
// GetDragPayload() returns null). Empty cells are NOT drag sources, so they
// fall through to window.Draggable below (IA-12 whole-window-drag), keeping
// the bar movable by its empty cells / chrome.
_dragCandidate = true;
}
else if (target.CapturesPointerDrag)
{
// The pressed widget owns interior drags (e.g. text selection):
@ -445,12 +476,64 @@ public sealed class UiRoot : UiElement
public void SetCapture(UiElement e) => Captured = e;
public void ReleaseCapture() => Captured = null;
// ── Window manager (named top-level windows: Show / Hide / Toggle) ───
private readonly Dictionary<string, UiElement> _windows = new();
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
/// Does NOT add it to the tree — the caller mounts via AddChild and controls
/// initial Visible. Idempotent (re-register replaces). The dict is the source
/// of truth — independent of UiElement.Name (init-only, not set here).</summary>
public void RegisterWindow(string name, UiElement window) => _windows[name] = window;
/// <summary>Make the named window visible. No-op (returns false) if unknown.</summary>
public bool ShowWindow(string name)
{
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = true;
BringToFront(w);
return true;
}
/// <summary>Hide the named window. No-op (returns false) if unknown.</summary>
public bool HideWindow(string name)
{
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = false;
return true;
}
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
/// Returns the new IsVisible state (false for an unknown name).</summary>
public bool ToggleWindow(string name)
{
if (!_windows.TryGetValue(name, out var w)) return false;
if (w.Visible) { HideWindow(name); return false; }
ShowWindow(name);
return true;
}
/// <summary>Raise a top-level window above its siblings by setting its ZOrder
/// one past the current max among the OTHER top-level children. Used on Show
/// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child.</summary>
public void BringToFront(UiElement window)
{
int top = window.ZOrder;
foreach (var c in Children)
if (!ReferenceEquals(c, window))
top = System.Math.Max(top, c.ZOrder + 1);
window.ZOrder = top;
}
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
private void BeginDrag(UiElement source, object? payload)
private void BeginDrag(UiElement source)
{
var payload = source.GetDragPayload();
if (payload is null) { _dragCandidate = false; return; }
DragSource = source;
DragPayload = payload;
_dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
source.OnEvent(in e);
}
@ -483,16 +566,18 @@ public sealed class UiRoot : UiElement
private void FinishDrag(int x, int y)
{
var (t, lx, ly) = HitTestTopDown(x, y);
var target = t ?? DragSource!;
var accepted = t is not null && t != DragSource;
var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased,
Data0: accepted ? 1 : 0,
Data1: (int)lx, Data2: (int)ly,
Payload: DragPayload);
target.OnEvent(in e);
if (t is not null)
{
// Dropped on a real element — deliver DropReleased; the hit cell's handler places.
// A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal.
var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased,
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
t.OnEvent(in e);
}
// else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail).
DragSource = null;
DragPayload = null;
_dragGhost = null;
_lastDragHoverTarget = null;
}
@ -593,21 +678,23 @@ public sealed class UiRoot : UiElement
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
e &= w.ResizableEdges;
return e;
}
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
/// move the origin so the opposite edge stays put.</summary>
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>) and
/// (<paramref name="maxW"/>,<paramref name="maxH"/>). Left/Top edges move the
/// origin so the opposite edge stays put.</summary>
public static (float x, float y, float w, float h) ResizeRect(
float startX, float startY, float startW, float startH,
ResizeEdges edges, float dx, float dy, float minW, float minH)
ResizeEdges edges, float dx, float dy, float minW, float minH, float maxW, float maxH)
{
float x = startX, y = startY, w = startW, h = startH;
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx);
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy);
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; }
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; }
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Clamp(startW + dx, minW, maxW);
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Clamp(startH + dy, minH, maxH);
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Clamp(startW - dx, minW, maxW); x = startX + (startW - nw); w = nw; }
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Clamp(startH - dy, minH, maxH); y = startY + (startH - nh); h = nh; }
return (x, y, w, h);
}

View file

@ -0,0 +1,8 @@
namespace AcDream.App.UI;
/// <summary>Canonical registry names for top-level retail-UI windows, so the
/// mount, the window registry, and the toggle keybind all agree on one literal.</summary>
public static class WindowNames
{
public const string Inventory = "inventory";
}

View file

@ -65,7 +65,10 @@ public static class GameEventWiring
// D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the
// toolbar can populate itself at login without keeping a parser reference.
// Optional so all existing callers and tests compile unchanged.
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null)
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
// B-Wire: the local player's server guid. When provided, the PD handler upserts
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -235,17 +238,65 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.WieldObject, e =>
{
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is not null) items.MoveItem(
if (p is null) return;
items.MoveItem(
p.Value.ItemGuid,
newContainerId: p.Value.WielderGuid,
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
});
dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
{
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid,
newSlot: (int)p.Value.Placement);
if (p is null) return;
items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement);
items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move
});
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
// opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and
// record the new entries in order (ContainerSlot = index — ACE writes entries
// OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController)
// repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents.
dispatcher.Register(GameEventType.ViewContents, e =>
{
var p = GameEvents.ParseViewContents(e.Payload.Span);
if (p is null) return;
var guids = new uint[p.Value.Items.Count];
for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid;
items.ReplaceContents(p.Value.ContainerGuid, guids);
});
// B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
// to the world. Unparent it from its container (it's now a ground object) so
// the inventory grid drops the cell; the object itself survives.
dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e =>
{
var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u);
});
// B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move.
// Snap the item back to its pre-move slot. Log only when there was no pending move
// (a server-initiated failure on a non-optimistic path).
dispatcher.Register(GameEventType.InventoryServerSaveFailed, e =>
{
var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span);
if (p is null) return;
// B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot.
if (!items.RollbackMove(p.Value.ItemGuid))
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)");
});
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
// view. No table change (the view is UI-only, wired in container-open); log.
dispatcher.Register(GameEventType.CloseGroundContainer, e =>
{
var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span);
if (guid is not null)
Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}");
});
dispatcher.Register(GameEventType.IdentifyObjectResponse, e =>
{
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
@ -289,6 +340,13 @@ public static class GameEventWiring
Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}");
if (p is null) return;
// B-Wire: deliver the player's OWN properties to the player ClientObject.
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
// CreateObject; the player's own stats legitimately come from PD.) Upsert
// because PD can arrive before the player's CreateObject. Retires AP-48/AP-49.
if (playerGuid is not null)
items.UpsertProperties(playerGuid(), p.Value.Properties);
// K-fix13 (2026-04-26): build attrId → current map while
// iterating attributes so the skill-formula resolver below
// can apply (attr1.current * mult1 + attr2.current * mult2)

View file

@ -17,11 +17,23 @@ public static class DeleteObject
{
public const uint Opcode = 0xF747u;
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
/// <param name="FromPickup">
/// <see langword="true"/> when this delete was sourced from a
/// <c>PickupEvent (0xF74A)</c> — the object left the 3-D world view but
/// the weenie record still exists (it is moving into a container).
/// <see langword="false"/> (default) when sourced from <c>DeleteObject
/// (0xF747)</c> — the weenie was destroyed and must be evicted from
/// <see cref="AcDream.Core.Items.ClientObjectTable"/>.
/// Retail two-table model: PickupEvent removes from <c>object_table</c>;
/// DeleteObject removes from <c>weenie_object_table</c>.
/// </param>
public readonly record struct Parsed(uint Guid, ushort InstanceSequence, bool FromPickup = false);
/// <summary>
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
/// 4-byte opcode, matching every other parser in this namespace.
/// The returned <see cref="Parsed.FromPickup"/> is always <see langword="false"/>
/// — this parser handles true destroys only.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
@ -34,6 +46,6 @@ public static class DeleteObject
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
return new Parsed(guid, instanceSequence);
return new Parsed(guid, instanceSequence, FromPickup: false);
}
}

View file

@ -343,19 +343,47 @@ public static class GameEvents
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
}
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.</summary>
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.
/// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid,
/// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed
/// vs holtburger events.rs fixture (slot=3 type=1).</summary>
public readonly record struct InventoryPutObjInContainer(
uint ItemGuid,
uint ContainerGuid,
uint Placement);
uint Placement,
uint ContainerType);
public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan<byte> payload)
{
if (payload.Length < 12) return null;
if (payload.Length < 16) return null;
return new InventoryPutObjInContainer(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
}
/// <summary>0x0196 ViewContents: full contents list of a container you opened.
/// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count.
/// Client consumer: ClientUISystem::OnViewContents (PackableList&lt;ContentProfile&gt;).</summary>
public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType);
public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList<ViewContentsEntry> Items);
public static ViewContents? ParseViewContents(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
int pos = 8;
if ((long)payload.Length - pos < (long)count * 8) return null;
var items = new ViewContentsEntry[count];
for (int i = 0; i < count; i++)
{
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
items[i] = new ViewContentsEntry(guid, type);
}
return new ViewContents(containerGuid, items);
}
// ── Other small-payload events ──────────────────────────────────────────
@ -374,11 +402,17 @@ public static class GameEvents
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.</summary>
public static uint? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
/// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.
/// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger
/// events.rs:147 reads both fields.</summary>
public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError);
public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
if (payload.Length < 8) return null;
return new InventoryServerSaveFailed(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x0052 CloseGroundContainer: server closed a ground container view.</summary>

View file

@ -22,6 +22,9 @@ public static class InventoryActions
public const uint AddShortcutOpcode = 0x019Cu;
public const uint RemoveShortcutOpcode = 0x019Du;
public const uint TeleToPoiOpcode = 0x00B1u;
public const uint GetAndWieldItemOpcode = 0x001Au;
public const uint DropItemOpcode = 0x001Bu;
public const uint NoLongerViewingContentsOpcode = 0x0195u;
/// <summary>
/// Merge stack A into stack B of the same item type. Server validates
@ -95,17 +98,20 @@ public static class InventoryActions
return body;
}
/// <summary>Pin an item / spell to a quickbar slot.</summary>
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32),
/// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive
/// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0.</summary>
public static byte[] BuildAddShortcut(
uint seq, uint slotIndex, uint objectType, uint targetId)
uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), slotIndex);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectType);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), targetId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
return body;
}
@ -130,4 +136,39 @@ public static class InventoryActions
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), poiId);
return body;
}
/// <summary>Drop an item on the ground. ACE GameActionDropItem.Handle reads 1 u32.</summary>
public static byte[] BuildDropItem(uint seq, uint itemGuid)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), DropItemOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
return body;
}
/// <summary>Equip an item from inventory onto the doll. holtburger actions.rs
/// GetAndWieldItemActionData = (itemGuid, equipMask).</summary>
public static byte[] BuildGetAndWieldItem(uint seq, uint itemGuid, uint equipMask)
{
byte[] body = new byte[20];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), GetAndWieldItemOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), equipMask);
return body;
}
/// <summary>Close a side-pack / ground-container view. holtburger actions.rs = (containerGuid).</summary>
public static byte[] BuildNoLongerViewingContents(uint seq, uint containerGuid)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), NoLongerViewingContentsOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid);
return body;
}
}

View file

@ -0,0 +1,31 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>InventoryRemoveObject (0x0024)</c> — a top-level GameMessage (UIQueue),
/// NOT a GameEvent. The server tells the client an object left its inventory view
/// (given away / sold / destroyed). The client drops it from object maintenance.
///
/// <para>Wire layout (ACE <c>GameMessageInventoryRemoveObject.cs</c>, size hint 8):</para>
/// <code>
/// u32 opcode = 0x0024
/// u32 guid
/// </code>
/// </summary>
public static class InventoryRemoveObject
{
public const uint Opcode = 0x0024u;
public readonly record struct Parsed(uint Guid);
/// <summary>Parse a raw 0x0024 body. Returns null on opcode mismatch / truncation.</summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 8) return null; // 4 + 4
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]);
return new Parsed(guid);
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>PrivateUpdatePropertyInt (0x02CD)</c> — the server updates one
/// <c>PropertyInt</c> on the player's OWN object. Unlike the sibling
/// <see cref="PublicUpdatePropertyInt"/> (0x02CE), this carries NO guid (it
/// implicitly targets the local player). Burden (<c>EncumbranceVal</c>, PropertyInt 5)
/// changes ride this opcode on every pick-up / drop.
///
/// <para>Wire layout (ACE <c>GameMessagePrivateUpdatePropertyInt</c>):</para>
/// <code>
/// u32 opcode = 0x02CD
/// u8 sequence // single byte (ByteSequence) — not honored, latest-wins (DR-4)
/// u32 property // PropertyInt enum
/// i32 value
/// </code>
/// </summary>
public static class PrivateUpdatePropertyInt
{
public const uint Opcode = 0x02CDu;
public readonly record struct Parsed(uint Property, int Value);
/// <summary>Parse a raw 0x02CD body. Returns null on opcode mismatch / truncation.</summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 13) return null; // 4 + 1 + 4 + 4
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
int pos = 4;
pos += 1; // sequence byte (not honored)
uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
return new Parsed(prop, value);
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>SetStackSize (0x0197)</c> — a top-level GameMessage (UIQueue group),
/// NOT a GameEvent. The server updates a stack's count + value after a merge / split.
/// Client consumer: ACCWeenieObject::ServerSaysSetStackSize.
///
/// <para>Wire layout (ACE <c>GameMessageSetStackSize.cs</c>, size hint 17):</para>
/// <code>
/// u32 opcode = 0x0197
/// u8 sequence // ByteSequence (UpdatePropertyInt) — not honored
/// u32 guid
/// i32 stackSize
/// i32 value
/// </code>
/// </summary>
public static class SetStackSize
{
public const uint Opcode = 0x0197u;
public readonly record struct Parsed(uint Guid, int StackSize, int Value);
/// <summary>Parse a raw 0x0197 body. Returns null on opcode mismatch / truncation.</summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
int pos = 4;
pos += 1; // sequence byte (not honored)
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
int stack = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); pos += 4;
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
return new Parsed(guid, stack, value);
}
}

View file

@ -5,7 +5,9 @@ namespace AcDream.Core.Net;
/// <summary>
/// Wires WorldSession GameMessage-level object events into the client object
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
/// = evict, PublicUpdatePropertyInt (0x02CE) UiEffects = live icon re-composite.
/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject
/// (0x0024) = inventory-view removal.
/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
/// </summary>
@ -16,18 +18,44 @@ public static class ObjectTableWiring
/// on <paramref name="session"/>. Call this BEFORE the render handler subscribes
/// to EntitySpawned so the table is populated before the render path runs.
/// </summary>
public static void Wire(WorldSession session, ClientObjectTable table)
public static void Wire(WorldSession session, ClientObjectTable table, Func<uint>? playerGuid = null)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(table);
session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
session.EntityDeleted += d => table.Remove(d.Guid);
// Retail two-table model:
// PickupEvent (0xF74A) → object left the 3-D world view; the weenie persists
// (it is moving into a container, e.g. unwield-to-pack). Remove the 3-D
// render entity only — do NOT evict from ClientObjectTable.
// DeleteObject (0xF747) → weenie is DESTROYED; evict from both tables.
// FromPickup = true guards the weenie eviction so it only fires for true destroys.
session.EntityDeleted += d => { if (!d.FromPickup) table.Remove(d.Guid); };
// B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just
// UiEffects — the server is the authority on object properties. UpdateIntProperty
// stores it in the bundle and still mirrors UiEffects → the typed Effects field.
session.ObjectIntPropertyUpdated += u =>
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
// B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the
// local player. Route it to the player object so live EncumbranceVal updates the
// burden bar. The player ClientObject is created at login by the PD UpsertProperties
// call (which precedes any live 0x02CD), so UpdateIntProperty finds it. If it somehow
// hasn't yet, this no-ops (UpdateIntProperty returns false on an unknown guid) rather
// than creating a phantom — the next PD / CreateObject seeds it.
session.PlayerIntPropertyUpdated += u =>
{
if (u.Property == ClientObjectTable.UiEffectsPropertyId)
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
if (playerGuid is not null)
table.UpdateIntProperty(playerGuid(), u.Property, u.Value);
};
// B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value);
// B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory
// view; drop it from the table (retail ClientUISystem removes it from maintenance).
session.InventoryObjectRemoved += guid => table.Remove(guid);
}
/// <summary>Translate the wire spawn into the table's merge patch.</summary>

View file

@ -196,6 +196,25 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<ObjectIntPropertyUpdate>? ObjectIntPropertyUpdated;
/// <summary>Payload for <see cref="PlayerIntPropertyUpdated"/>: a PropertyInt change on
/// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire).</summary>
public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value);
/// <summary>Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one
/// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar.</summary>
public event Action<PlayerIntPropertyUpdate>? PlayerIntPropertyUpdated;
/// <summary>Payload for <see cref="StackSizeUpdated"/>: SetStackSize (0x0197) — a stack's
/// count + value after a merge / split.</summary>
public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value);
/// <summary>Fires when the session parses a SetStackSize (0x0197) top-level GameMessage.</summary>
public event Action<StackSizeUpdate>? StackSizeUpdated;
/// <summary>Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left
/// the player's inventory view.</summary>
public event Action<uint>? InventoryObjectRemoved;
/// <summary>
/// Fires when the server sends a PlayerTeleport (0xF751) game message,
/// signalling that the player is entering portal space. The uint payload
@ -817,16 +836,18 @@ public sealed class WorldSession : IDisposable
}
else if (op == PickupEvent.Opcode)
{
// ACE sends PickupEvent (0xF74A) instead of DeleteObject
// when a player picks up a world item (Player_Tracking
// .RemoveTrackedObject with fromPickup=true). Downstream
// view-removal semantics are identical, so we adapt to
// DeleteObject.Parsed and reuse the existing handler.
// ACE sends PickupEvent (0xF74A) when an object leaves the 3-D world view
// (player picks it up, or a player unwields gear that goes back to their pack).
// The WEENIE is NOT destroyed — it is moving into a container. We adapt to
// DeleteObject.Parsed so the render/entity layer (GameWindow.OnLiveEntityDeleted)
// still removes the 3-D WorldEntity, but FromPickup = true tells ObjectTableWiring
// to skip the ClientObjectTable eviction (retail two-table model: PickupEvent
// targets object_table only; DeleteObject 0xF747 targets weenie_object_table).
var parsed = PickupEvent.TryParse(body);
if (parsed is not null)
EntityDeleted?.Invoke(
new DeleteObject.Parsed(
parsed.Value.Guid, parsed.Value.InstanceSequence));
parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true));
}
else if (op == UpdateMotion.Opcode)
{
@ -987,6 +1008,25 @@ public sealed class WorldSession : IDisposable
ObjectIntPropertyUpdated?.Invoke(
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
}
else if (op == PrivateUpdatePropertyInt.Opcode)
{
var p = PrivateUpdatePropertyInt.TryParse(body);
if (p is not null)
PlayerIntPropertyUpdated?.Invoke(
new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value));
}
else if (op == SetStackSize.Opcode)
{
var p = SetStackSize.TryParse(body);
if (p is not null)
StackSizeUpdated?.Invoke(
new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value));
}
else if (op == InventoryRemoveObject.Opcode)
{
var p = InventoryRemoveObject.TryParse(body);
if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid);
}
else if (op == GameEventEnvelope.Opcode)
{
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
@ -1169,6 +1209,61 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
}
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.
/// Retail: CM_Character::Event_RemoveShortCut.</summary>
public void SendRemoveShortcut(uint index)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
}
/// <summary>Send DropItem (0x001B) — drop an item on the ground.</summary>
public void SendDropItem(uint itemGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
}
/// <summary>Send GetAndWieldItem (0x001A) — equip an item to an equip slot.</summary>
public void SendGetAndWieldItem(uint itemGuid, uint equipMask)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask));
}
/// <summary>Send NoLongerViewingContents (0x0195) — close a container view.</summary>
public void SendNoLongerViewingContents(uint containerGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid));
}
/// <summary>Send Use (0x0036) — open/use an object by guid (e.g. open a container in your
/// inventory). A direct wire send: opening a pack you already hold needs no autowalk, unlike
/// GameWindow.SendUse (the world-interaction path). Retail: CM_Physics::Event_Use.</summary>
public void SendUse(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InteractRequests.BuildUse(seq, targetGuid));
}
/// <summary>Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
/// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer.</summary>
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
{
uint seq = NextGameActionSequence();
SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement));
}
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
/// <remarks>
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>

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