Visual verification surfaced two render bugs (controller LOGIC was already correct): 1. BACKDROP WASH-OUT (the big one — #145 continuation). The mounted backpack/ 3D-items panels inherited their sub-window root's ZLevel 1000 via the merge's zero-wins-base rule. The #145 ZOrder fold (ReadOrder − ZLevel·10000) turned 1000 into ZOrder ≈ −10,000,000 — sinking the panels BEHIND the frame's Alphablend backdrop (ZLevel 100 → ≈ −1,000,000). The backdrop then overpainted the panels' captions/burden-meter/cells (the paperdoll root is ZLevel 0 so it escaped, which is why the previous session thought #145 was done). Fix: the sub-window mount now keeps each slot's OWN frame ZLevel, so panels sit in front of the backdrop. Root-caused via a one-shot sprite-segment-order dump (backdrop was painting after the panel content) + a live ZLevel probe. 2. CAPTIONS. The caption elements resolve to UiText; driving a nested child UiText didn't paint. AttachCaption now drives the host UiText directly. Locked by InventoryFrameImportProbe (real-dat smoke: asserts each mounted panel's ZOrder > the backdrop's). Visually confirmed by the user: dark backdrop behind, Burden 17% + vertical bar + Contents-of-Backpack + full item grid all visible. Build + App(532)/Core(1526) tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
239 lines
10 KiB
C#
239 lines
10 KiB
C#
using System;
|
||
using System.Numerics;
|
||
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).
|
||
/// Read-only: no container switching / drag / wield-drop wire (later sub-phases).
|
||
/// </summary>
|
||
public sealed class InventoryController
|
||
{
|
||
// 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"
|
||
|
||
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
|
||
private const int ContentsColumns = 6;
|
||
private const float CellPx = 32f;
|
||
|
||
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);
|
||
|
||
// 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)
|
||
{
|
||
_objects = objects;
|
||
_playerGuid = playerGuid;
|
||
_iconIds = iconIds;
|
||
_strength = strength;
|
||
|
||
_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 = CellPx;
|
||
_contentsGrid.CellHeight = CellPx;
|
||
}
|
||
if (_containerList is not null)
|
||
{
|
||
_containerList.Columns = 1;
|
||
_containerList.CellWidth = CellPx;
|
||
_containerList.CellHeight = CellPx;
|
||
}
|
||
|
||
// 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 Backpack", 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)
|
||
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont);
|
||
|
||
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.ContainerId == p || o.WielderId == p
|
||
|| (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep
|
||
}
|
||
|
||
/// <summary>Retail gm*UI display refresh: partition the player's direct contents into loose
|
||
/// items (the "Contents of Backpack" grid) and side bags (the pack-selector list). The
|
||
/// container index (<see cref="ClientObjectTable.GetContents"/>) IS retail's per-container
|
||
/// item list — items enter it via the CreateObject (Ingest) / server-move (MoveItem) flows
|
||
/// that call Reindex, slot-sorted.</summary>
|
||
public void Populate()
|
||
{
|
||
uint p = _playerGuid();
|
||
var contents = _objects.GetContents(p);
|
||
|
||
_contentsGrid?.Flush();
|
||
_containerList?.Flush();
|
||
|
||
foreach (var guid in contents)
|
||
{
|
||
var item = _objects.Get(guid);
|
||
if (item is null) continue;
|
||
// Equipped items belong on the paperdoll (Sub-phase C), never in the pack grid or
|
||
// the side-bag list. A mid-session self-wield routes an item through
|
||
// MoveItem(item, WielderGuid=player), so it transiently lands in GetContents(player);
|
||
// retail's gm3DItemsUI shows pack contents only, gated on the wielded location.
|
||
if (item.CurrentlyEquippedLocation != EquipMask.None) continue;
|
||
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
|
||
var list = isBag ? _containerList : _contentsGrid;
|
||
if (list is null) continue;
|
||
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId,
|
||
item.IconOverlayId, item.Effects);
|
||
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
|
||
cell.SetItem(guid, tex);
|
||
list.AddItem(cell);
|
||
}
|
||
|
||
// Main-pack cell: the player's own container. Icon = placeholder until a backpack
|
||
// RenderSurface DID is pinned (spec §3 / divergence row). Bind the guid so a future
|
||
// click can select it.
|
||
if (_topContainer is not null)
|
||
{
|
||
_topContainer.Flush();
|
||
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
||
main.SetItem(p, 0u);
|
||
_topContainer.AddItem(main);
|
||
}
|
||
|
||
RefreshBurden();
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|