acdream/src/AcDream.App/UI/Layout/InventoryController.cs
Erik 417b1375fd fix(ui): D.2b-B — inventory panels render over the backdrop (#145 continuation) + captions
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>
2026-06-21 17:58:44 +02:00

239 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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