using System; using System.Numerics; using AcDream.App.UI; using AcDream.Core.Items; using AcDream.Core.Selection; namespace AcDream.App.UI.Layout; /// /// Controller for the action bar's selected-object strip (ids 0x1000019E–0x100001A4). /// Analogue of retail gmToolbarUI::HandleSelectionChanged /// (docs/research/named-retail/acclient_2013_pseudo_c.txt:198635) + /// RecvNotice_UpdateObjectHealth (:196213) + /// RecvNotice_UpdateItemMana (:196188). /// /// /// On selection change: clears the strip (name, overlay flash, health meter), then if a /// guid is provided it sets the name, flashes the selection overlay briefly, and sends /// either QueryHealth (0x01BF) for health-bearing targets or /// QueryItemMana (0x0263) for owned non-stack items. The Health meter /// becomes visible only when the server actually reports health for the selected guid — /// either an UpdateHealth (0x01C0) arrives (retail /// RecvNotice_UpdateObjectHealthSetVisible(1)) or the value is already /// cached. So a friendly NPC you have not assessed shows name-only (no bar), and a /// monster's bar appears after damage / a successful assess — matching retail. /// /// /// /// Retail element roles (PostInit, :198119): m_pSelObjectField /// is the container 0x1000019E whose SetState(0x1000000b/0c) drives a /// 0.25s Pause→Normal flash that cascades to the overlay child's green frame. /// acdream has no state-cascade / transition-animation system, so this controller drives /// the overlay element 0x100001A0 directly and reverts it after the same /// to reproduce the brief flash. The name element /// 0x1000019F is bumped to the top of the strip's z-order so it draws OVER the /// overlay frame and the health bar (retail draws the name over the bar — see the /// "Drudge Slinker" reference shot). /// /// /// public sealed class SelectedObjectController : IRetainedPanelController { // ── Element ids (toolbar LayoutDesc 0x21000016) ───────────────────────── /// Selected-object container / field element id (retail m_pSelObjectField). public const uint ContainerId = 0x1000019E; /// Selected-object name element id (retail m_pSelObjectName, UIElement_Text). public const uint NameId = 0x1000019F; /// Selected-object overlay element id (states: ObjectSelected / StackedItemSelected). public const uint OverlayId = 0x100001A0; /// Selected-object health meter element id (retail m_pSelObjectHealthMeter). public const uint HealthMeterId = 0x100001A1; /// Selected-object item-mana meter element id (retail m_pSelObjectManaMeter). public const uint ManaMeterId = 0x100001A2; /// Editable stack quantity (retail m_pStackSizeEntryBox). public const uint StackSizeEntryId = 0x100001A3; /// Horizontal stack quantity slider (retail m_pStackSizeSlider). public const uint StackSizeSliderId = 0x100001A4; /// Selection-overlay flash duration — retail's container ObjectSelected state is a /// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E). private const double FlashSeconds = 0.25; /// Z-order for the name so it draws OVER the overlay frame + health bar. /// The strip's other children sit at ReadOrder 1–4; this floats the name to the top. private const int NameZOrderOnTop = 1_000_000; /// Z-order for the selection-flash overlay — above the health meter (so the green /// flash isn't hidden by the bar) but below the name (so the name stays readable). private const int OverlayZOrder = NameZOrderOnTop - 1; /// Height (px) of the black name band at the top of the 31px bar sprite. The name /// label is constrained to this band (top-aligned) so the health bar shows below it — /// retail "name on the black, bar below". The bar sprite's colored region starts ~y14. private const float NameBandHeight = 15f; // ── Found elements (any may be null for partial/test layouts) ─────────── private readonly UiElement? _name; private readonly UiDatElement? _overlay; private readonly UiMeter? _healthMeter; private readonly UiMeter? _manaMeter; private readonly UiField? _stackSizeEntry; private readonly UiScrollbar? _stackSizeSlider; // ── Captured delegates ─────────────────────────────────────────────────── private readonly Func _isHealthTarget; private readonly Func _isOwnedByPlayer; private readonly Func _resolveName; private readonly Func _healthPercent; private readonly Func _hasHealth; private readonly Func _stackSize; private readonly Action _sendQueryHealth; private readonly Func _manaPercent; private readonly Action _sendQueryItemMana; private readonly StackSplitQuantityState _splitQuantity; private readonly SelectionState _selection; private readonly Func _isVendorSplitExempt; private readonly Action> _unsubscribeHealthChanged; private readonly Action> _unsubscribeItemManaChanged; private readonly Action> _unsubscribeObjectUpdated; // ── Live state (read by closures on the per-frame draw path) ──────────── private uint? _current; private string? _currentName; private double _flashRemaining; // > 0 while the selection overlay is flashing private bool _changingSplitFromSlider; private bool _disposed; /// White label color for the name line. private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f); private SelectedObjectController( ImportedLayout layout, SelectionState selection, Action> subscribeHealthChanged, Action> unsubscribeHealthChanged, Action> subscribeItemManaChanged, Action> unsubscribeItemManaChanged, Func isHealthTarget, Func isOwnedByPlayer, Func name, Func healthPercent, Func hasHealth, Func stackSize, Action sendQueryHealth, Func manaPercent, Action sendQueryItemMana, UiDatFont? datFont, StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated, Func isVendorSplitExempt) { _isHealthTarget = isHealthTarget; _isOwnedByPlayer = isOwnedByPlayer; _resolveName = name; _healthPercent = healthPercent; _hasHealth = hasHealth; _stackSize = stackSize; _sendQueryHealth = sendQueryHealth; _manaPercent = manaPercent; _sendQueryItemMana = sendQueryItemMana; _splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _isVendorSplitExempt = isVendorSplitExempt ?? throw new ArgumentNullException(nameof(isVendorSplitExempt)); _unsubscribeHealthChanged = unsubscribeHealthChanged; _unsubscribeItemManaChanged = unsubscribeItemManaChanged; _unsubscribeObjectUpdated = unsubscribeObjectUpdated; // Find elements — silently skip absent ones (partial/test layouts). _name = layout.FindElement(NameId); _overlay = layout.FindElement(OverlayId) as UiDatElement; _healthMeter = layout.FindElement(HealthMeterId) as UiMeter; _manaMeter = layout.FindElement(ManaMeterId) as UiMeter; _stackSizeEntry = layout.FindElement(StackSizeEntryId) as UiField; _stackSizeSlider = layout.FindElement(StackSizeSliderId) as UiScrollbar; // The selection-flash overlay must draw OVER the health meter (which spans the whole // strip) — otherwise the meter hides the green flash whenever a bar is visible (i.e. // for players/monsters). Float it just below the name so the name stays readable. if (_overlay is not null) _overlay.ZOrder = OverlayZOrder; // This controller owns the health meter's initial-hidden state. if (_healthMeter is not null) { _healthMeter.Visible = false; // Fill polls live: _current holds the currently-selected guid (or null). _healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f; } if (_manaMeter is not null) { _manaMeter.Visible = false; _manaMeter.Fill = () => _current is uint g ? _manaPercent(g) : (float?)0f; } if (_stackSizeEntry is not null) { _stackSizeEntry.Visible = false; // #353: the entry is AUTHORED HJustify=2 (right) at X=0 W=50, // flush against the slider at X=50 on the same row — the count // reads right-adjacent to the bar, retail's look. UiField // already supports it; the importer does not carry HJustify. // OneLine is REQUIRED for the alignment: RightAligned only // applies on the single-line draw path, and the multi-line // path's per-keystroke scroll-extent churn was also the edit // flicker the user reported. A 14 px numeric entry is // single-line by construction. _stackSizeEntry.OneLine = true; _stackSizeEntry.RightAligned = true; _stackSizeEntry.Selectable = true; _stackSizeEntry.ClearOnSubmit = false; _stackSizeEntry.RecordHistory = false; _stackSizeEntry.CharacterFilter = static c => c is >= '0' and <= '9'; _stackSizeEntry.SelectAllOnFocus = true; _stackSizeEntry.OnSubmit = CommitStackEntry; _stackSizeEntry.OnFocusLost = CommitStackEntry; } if (_stackSizeSlider is not null) { _stackSizeSlider.Visible = false; _stackSizeSlider.Horizontal = true; _stackSizeSlider.SetScalarPosition(_splitQuantity.Ratio); _stackSizeSlider.ScalarChanged = OnStackSliderChanged; } // Attach a centered UiText child to the name element for the object name display. // Mirrors VitalsController.BindMeter's number attach. The name is floated to the // top of the strip's z-order so it draws OVER the overlay frame and the health bar // (retail renders the object name over the bar). // // The bar sprite (0x0600193E/F, 146x31) carries a ~14px BLACK name band across its // TOP with the colored bar in the lower portion (confirmed from the dat). Retail // draws the object name in that black band with the health bar BELOW it — so the // label is TOP-aligned by constraining its height to the band, not centered over the // whole 31px strip (which overlapped the bar's middle). if (_name is not null) { _name.ZOrder = NameZOrderOnTop; // #353: the name element is AUTHORED two lines tall (H=31 at // W=140) — a long name wraps at the authored PIXEL width onto a // second row instead of overflowing (user-verified retail // behavior). Two stacked centered one-line labels reuse the // existing centered draw path; the second draws nothing when // the name fits. float nameWidth = _name.Width; var wrapFont = datFont; Func lineFor = index => { var n = _currentName; if (string.IsNullOrEmpty(n)) return Array.Empty(); (string first, string second) = WrapNameTwoLines(n, nameWidth, wrapFont); string text = index == 0 ? first : second; return text.Length == 0 ? Array.Empty() : new[] { new UiText.Line(text, NameColor) }; }; for (int lineIndex = 0; lineIndex < 2; lineIndex++) { int captured = lineIndex; var label = new UiText { Left = 0f, Top = captured * NameBandHeight, Width = _name.Width, Height = NameBandHeight, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, Centered = true, OneLine = true, DatFont = datFont, ClickThrough = true, AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false, LinesProvider = () => lineFor(captured), }; _name.AddChild(label); } } // Register the handlers LAST so the initial state is fully set up first. _selection.Changed += OnSelectionTransition; _splitQuantity.Changed += OnSplitQuantityChanged; subscribeHealthChanged(OnHealthChanged); subscribeItemManaChanged(OnItemManaChanged); subscribeObjectUpdated(OnObjectUpdated); if (_selection.SelectedObjectId is { } initial) ApplySelection(initial); } /// /// Create and bind a to . /// Port of retail gmToolbarUI::HandleSelectionChanged + RecvNotice_UpdateObjectHealth. /// /// Imported toolbar layout (LayoutDesc 0x21000016). /// The single Core selected-object owner. /// Called once with /// (typical host: h => Combat.HealthChanged += h) — drives meter visibility. /// Returns true for guids that may show a health meter /// (proxy for retail's IsPlayer() || pet_owner || ObjectIsAttackable()). /// Returns retail's NAME_APPROPRIATE display name for a guid. /// Returns the health fill fraction [0..1] for a given guid. /// Returns true if real health has been received for a guid /// (so a re-selected, already-known target shows its bar immediately). /// Returns the stack size for a guid (0 or 1 = non-stacked). /// Sends retail QueryHealth (0x01BF); may be a no-op offline. /// Dat font for the name label; null = debug bitmap font fallback. /// /// Slice 6.2: retail's gmToolbarUI::HandleSelectionChanged vendor /// branch (pc:198779-198790) — true when the selected guid is /// owned by the currently-open vendor (its ClientObject.ContainerId /// equals VendorState.VendorId) AND its type intersects /// . When true, a stack /// seeds to quantity 1 instead of the full authored stack size — see /// . F2 (Slice 6 review): /// VendorUiController.ResolveBuyQuantity answers the equivalent /// LIVE question for vendor's own display text and Buy dispatch (the /// CURRENT slider value, not the seed) via the SAME /// mask, so the mask exists in exactly /// one place (composed at InteractionRetainedUiComposition). /// public static SelectedObjectController Bind( ImportedLayout layout, SelectionState selection, Action> subscribeHealthChanged, Action> unsubscribeHealthChanged, Action> subscribeItemManaChanged, Action> unsubscribeItemManaChanged, Func isHealthTarget, Func isOwnedByPlayer, Func name, Func healthPercent, Func hasHealth, Func stackSize, Action sendQueryHealth, Func manaPercent, Action sendQueryItemMana, UiDatFont? datFont, StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated, Func isVendorSplitExempt) => new SelectedObjectController( layout, selection, subscribeHealthChanged, unsubscribeHealthChanged, subscribeItemManaChanged, unsubscribeItemManaChanged, isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize, sendQueryHealth, manaPercent, sendQueryItemMana, datFont, splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated, isVendorSplitExempt); /// /// Port of gmToolbarUI::HandleSelectionChanged (:198635): /// clear-then-populate the selected-object strip on any selection change. /// private void ApplySelection(uint? guid) { bool selectionChanged = _current != guid; // gmToolbarUI::HandleSelectionChanged @ 0x004BF3D1: changing away from // a visible meter cancels its server query with object id zero. if (selectionChanged) { if (_healthMeter?.Visible == true) _sendQueryHealth(0); if (_manaMeter?.Visible == true) _sendQueryItemMana(0); } // ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0) // + SetVisible(0) on the meters). ────────────────────────────────────── if (selectionChanged) { if (_healthMeter is not null) _healthMeter.Visible = false; if (_manaMeter is not null) _manaMeter.Visible = false; } if (_stackSizeEntry is not null) _stackSizeEntry.Visible = false; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = false; _splitQuantity.Reset(1u); _currentName = null; _current = guid; if (guid is null) { // Deselect: clear the overlay flash immediately too. SetOverlayState(UiStateInfo.DirectStateId); _flashRemaining = 0; return; } uint g = guid.Value; // ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ── uint stackSize = _stackSize(g); string? objectName = _resolveName(g); _currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName) ? $"{stackSize} {objectName}" : objectName; if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] ApplySelection guid=0x{g:X8} stackSizeOperand={stackSize} " + $"objectName={objectName ?? "null"} builtLabel={_currentName ?? "null"}"); } // ── 3. Selection overlay: brief flash (retail container ObjectSelected // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── SetOverlayState(stackSize > 1u ? RetailUiStateIds.StackedItemSelected : RetailUiStateIds.ObjectSelected); _flashRemaining = FlashSeconds; // gmToolbarUI::HandleSelectionChanged @ 0x004BF52D..0x004BF666: // stacks initialize to the full stack, show the numeric entry + horizontal // slider, and set the stacked selection state. Slice 6.2: the // vendor-owned branch (pc:198779-198790, mask literal pc:198784) // seeds splitSize (the INITIAL value) to 1 instead of the full stack // when the selection is owned by the currently-open vendor AND its // type intersects VendorSplitPolicy.SplitExemptMask — see the // isVendorSplitExempt parameter doc. maxSplitSize (the slider's // RANGE) is always the full authored stack size regardless of // exemption (research doc §B.3: "Sets GenItemHolder::splitSize = // seed, GenItemHolder::maxSplitSize = stackSize") — only the // starting VALUE differs, not the ceiling. if (stackSize > 1u) { bool vendorSplitExempt = _isVendorSplitExempt(g); uint seed = vendorSplitExempt ? 1u : stackSize; _splitQuantity.Reset(stackSize, initialValue: seed); if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true; if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=true " + $"isVendorSplitExempt={vendorSplitExempt} maxSplitSize={stackSize} seed={seed}"); } } else if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=false " + $"failingPredicate=stackSize<=1u stackSize={stackSize}"); } // ── 4. Health: query, and show the meter only if real health is already known. // Otherwise the meter appears when OnHealthChanged fires for this guid // (retail RecvNotice_UpdateObjectHealth :196213). ────────────────────────── if (stackSize <= 1u && _isHealthTarget(g)) { if (selectionChanged) _sendQueryHealth(g); if (_hasHealth(g) && _healthMeter is not null) _healthMeter.Visible = true; } else if (stackSize <= 1u && _isOwnedByPlayer(g)) { if (selectionChanged) _sendQueryItemMana(g); } } /// /// Port of gmToolbarUI::RecvNotice_UpdateObjectHealth (:196213): when the /// server reports health for the currently-selected guid, make the Health meter visible. /// The fill value is read live by the meter's provider. /// public void OnHealthChanged(uint guid, float percent) { if (_current is uint c && c == guid && _isHealthTarget(guid) && _healthMeter is not null) _healthMeter.Visible = true; } /// Per-frame tick: reverts the selection overlay after the brief flash window. public void Tick(double deltaSeconds) { if (_flashRemaining <= 0) return; _flashRemaining -= deltaSeconds; if (_flashRemaining <= 0) SetOverlayState(UiStateInfo.DirectStateId); // flash done → overlay back to blank } private void SetOverlayState(uint state) { _overlay?.TrySetRetailState(state); } /// /// Port of gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0. /// Invalid results cancel with object id zero; valid results reveal the meter. /// public void OnItemManaChanged(uint guid, float percent, bool valid) { if (_current != guid) return; if (!valid) { _sendQueryItemMana(0); return; } if (_manaMeter is not null) _manaMeter.Visible = true; } /// /// #353: greedy word wrap of the selected-object name into at most two /// lines at the authored pixel width. A single word longer than the /// width stays unbroken on its line (retail does not hyphenate). The /// second line carries everything remaining — the authored element is /// exactly two lines tall, so anything longer simply clips like retail. /// internal static (string First, string Second) WrapNameTwoLines( string name, float width, UiDatFont? font) { if (font is null || font.MeasureWidth(name) <= width) return (name, string.Empty); int breakAt = -1; for (int i = 0; i < name.Length; i++) { if (name[i] != ' ') continue; if (font.MeasureWidth(name[..i]) <= width) breakAt = i; else break; } if (breakAt <= 0) return (name, string.Empty); return (name[..breakAt], name[(breakAt + 1)..].TrimStart()); } private void CommitStackEntry(string text) => _splitQuantity.SetFromText(text); private void OnSplitQuantityChanged() { _stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); // UIElement_Scrollbar owns its live thumb position while dragging and only // broadcasts message 0xA. Entry/selection changes explicitly write attr 0x86. if (!_changingSplitFromSlider) _stackSizeSlider?.SetScalarPosition(_splitQuantity.Ratio); } private void OnStackSliderChanged(float position) { _changingSplitFromSlider = true; try { _splitQuantity.SetFromSliderRatio(position); } finally { _changingSplitFromSlider = false; } } private void OnObjectUpdated(ClientObject updated) { if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum) ApplySelection(updated.ObjectId); } private void OnSelectionTransition(SelectionTransition transition) { // Retail's CM_UI::SendNotice_SelectionChanged (0x00479F50) carries no // selected-id payload. gmToolbarUI::HandleSelectionChanged therefore // reads the live ACCWeenieObject::selectedID when its notice handler // runs. This matters when an earlier handler (ClientCombatSystem:: // AutoTarget) selects a replacement reentrantly: the outer "cleared" // notice must render that replacement, not stale transition data. _ = transition; ApplySelection(_selection.SelectedObjectId); } public void Dispose() { if (_disposed) return; _disposed = true; _selection.Changed -= OnSelectionTransition; _splitQuantity.Changed -= OnSplitQuantityChanged; _unsubscribeHealthChanged(OnHealthChanged); _unsubscribeItemManaChanged(OnItemManaChanged); _unsubscribeObjectUpdated(OnObjectUpdated); } }