acdream/src/AcDream.App/UI/UiCatalogSlot.cs
Erik 46ce6f238c feat: regen buffs, wand aura, spellbook assess, indicator press flash
Four reports from one gate round. Three were mine; the fourth I first
mis-explained, and the correction is the useful part.

**The vital regeneration rates were never cast.** Regeneration (health),
Rejuvenation (stamina) and Mana Renewal (mana) all landed in the catch-all
Other bucket, which is off by default. Retail words each of the three
differently and two of the six phrasings do not begin with "Increases the
caster's" at all:

    Increase caster's natural healing rate by 10%.                 <- and note "Increase"
    Increases your Health Regeneration Rate by 50%.                (Empyrean)
    Increases the rate at which the caster regains Stamina by 10%.
    Increases the caster's natural mana rate by 10%.

They are matched per vital, on by default, and ranked at the very tail of the
Life group so they finish the pass. The mana line had to be checked BEFORE the
generic "Increases the caster's X by N" match, which would otherwise read it as
a buff to a stat named "natural mana rate".

**Aura of Hermetic Link was the sixth aura line and the only one missed.**
"a magic casting implement's" is reached by none of the other alternatives, so
the wand's mana-conversion buff was silently in Other too.

**Right-clicking a spell in the spellbook did nothing.** I claimed this had
never worked; the user said it used to, and they were right -- I had checked
one file's history and concluded from it. The regression is 3e31b0ac, which
gave UiCatalogSlot its own RightClick case returning true unconditionally. On
any list that had not wired the examine seam -- the spellbook among them -- the
event was reported handled and UiRoot stopped bubbling. Two fixes: the row now
reports an unwired right-click UNHANDLED so bubbling continues, and the
spellbook wires the seam to the same appraisal window the spell bar uses.

Retail does this generically in the list rather than per window
(UIElement_ItemList::ListenToElementMessage @ 0x004E4F1F -> ExamineSpell
@ 0x00564A70), which is exactly why a per-controller seam could be forgotten
for one window and not another.

**No green flash when pressing an indicator.** Every indicator button authors
a full-size 0x100000F2 child whose DirectState is a draw-nothing File=0 image
and whose only other state, Normal_pressed, carries the green selector sprite
0x06004CE8 -- and the buttons author Normal_pressed with PassToChildren. But
UiButton.ConsumesDatChildren drops dat children at import, so the cascade had
nothing left to reach. The child is re-attached through the same repair the map
hotspot's rollover highlight already uses.

**tools/LayoutDump** is new, and is why the last two are diagnoses rather than
guesses: it prints an authored LayoutDesc tree -- geometry, edge modes, state
sets, PassToChildren, per-state media -- straight from the installed DATs.
"Does this button even have a pressed state?" was being answered by reading our
own importer and inferring; now it is read from the data.

Solution builds clean; 14,464 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:38:28 +02:00

224 lines
10 KiB
C#

using System;
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// Icon-list cell for non-weenie catalog entries such as spells and components.
/// It reuses the retail UIItemList geometry without pretending a spell id is an
/// object GUID; catalog drags stay distinct from physical item drag/drop.
/// </summary>
public sealed class UiCatalogSlot : UiItemSlot
{
public uint EntryId { get; set; }
public uint CatalogIconTexture { get; set; }
/// <summary>Optional second 32x32 layer (retail spell endowment item icon).</summary>
public uint CatalogOverlayTexture { get; set; }
public string Label { get; set; } = string.Empty;
public string? Detail { get; set; }
public bool ShowLabel { get; init; }
public uint BackgroundSprite { get; init; }
public UiDatFont? LabelFont { get; init; }
public Vector4 LabelColor { get; init; } = new(0.92f, 0.88f, 0.70f, 1f);
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Catalog slots are
/// controller-built (no authored element), so this is settable-only, for
/// parity with the other text-bearing widgets (round-5 review S2 —
/// per-STATE switching is AP-192).</summary>
public bool Outline { get; init; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; init; } = UiRenderContext.DefaultOutlineColor;
public float IconLeft { get; init; }
public float IconTop { get; init; }
public float IconWidth { get; init; }
public float IconHeight { get; init; }
public float LabelLeft { get; init; }
public float LabelWidth { get; init; }
public bool SelectionBehindContent { get; init; }
public new Action? Clicked { get; set; }
public new Action? DoubleClicked { get; set; }
public object? CatalogDragPayload { get; init; }
public Action<object>? DragBegan { get; init; }
public Action<object>? DragEnded { get; init; }
public Action<object>? Dropped { get; init; }
/// <summary>
/// Per-panel drag-over acceptance — the catalog-cell port of retail's
/// per-list drag handler deciding the rollover state
/// (<c>UIElement_ItemList::ItemList_DragOver</c> @ 0x004E3400 →
/// <c>m_dragHandler->OnItemListDragOver</c>; the spell bar's is
/// <c>SpellCastSubMenu::OnItemListDragOver</c> @ 0x004C5990). While a drag
/// hovers this cell, the returned acceptance drives the authored
/// DragAccept frame (accept 0x060011F9 / reject 0x060011F8). Null (the
/// default for every list that never set one) keeps the cell neutral —
/// no overlay, exactly the pre-existing presentation.
/// </summary>
public Func<object, ItemDragAcceptance>? DragOverAcceptance { get; init; }
protected override bool IsShortcutOccupied => EntryId != 0u;
public override bool IsEmptySlot => EntryId == 0u;
public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label;
public override bool IsDragSource => CatalogDragPayload is not null;
public override object? GetDragPayload() => CatalogDragPayload;
public override (uint tex, int w, int h)? GetDragGhost() =>
CatalogDragPayload is not null && CatalogIconTexture != 0u
? (CatalogIconTexture, 32, 32)
: null;
internal override void SetDragSourceActive(bool active, object? payload)
{
if (!active && payload is not null) DragEnded?.Invoke(payload);
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.MouseDown:
if (EntryId != 0u
&& FindList() is { PrimaryCatalogEntryPressed: { } pressed })
pressed(EntryId);
return true;
case UiEventType.Click:
Clicked?.Invoke();
return true;
case UiEventType.DoubleClick:
DoubleClicked?.Invoke();
return true;
case UiEventType.RightClick:
// Retail examines from the LIST, generically:
// UIElement_ItemList::ListenToElementMessage @ 0x004E4ECA takes
// the right-click branch and calls ExamineObject for a row with
// an itemID, ExamineSpell for one with a spellID.
//
// Returning true unconditionally here swallowed the event on any
// list that had not wired the seam -- the spellbook among them --
// so report it unhandled instead and let UiRoot keep bubbling.
if (EntryId == 0u
|| FindList() is not { ExamineCatalogEntryRequested: { } examine })
return false;
examine(EntryId);
return true;
case UiEventType.DragBegin:
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
return true;
case UiEventType.DragEnter:
// Pointer entered mid-drag: ask the panel's acceptance seam and show
// the authored frame (retail ItemList_DragOver → the list handler's
// SetDragAcceptState on the hovered UIItem's 0x1000045A child).
SetDragAcceptVisual(e.Payload is { } enterPayload
? DragOverAcceptance?.Invoke(enterPayload) switch
{
ItemDragAcceptance.Accept => DragAcceptState.Accept,
ItemDragAcceptance.Reject => DragAcceptState.Reject,
_ => DragAcceptState.None,
}
: DragAcceptState.None);
return true;
case UiEventType.DragOver:
// UiRoot fires DragOver on LEAVE — retail's dwParam1 == 0 reset to
// ItemSlot_DragOver_Normal (0x1000003F) @ 0x004E1438-0x004E1456.
SetDragAcceptVisual(DragAcceptState.None);
return true;
case UiEventType.DropReleased:
SetDragAcceptVisual(DragAcceptState.None);
if (e.Payload is not null) Dropped?.Invoke(e.Payload);
return true;
default:
return false;
}
}
protected override void OnDraw(UiRenderContext ctx)
{
if (BackgroundSprite != 0u && SpriteResolve is not null)
{
var (texture, _, _) = SpriteResolve(BackgroundSprite);
if (texture != 0u)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
if (SelectionBehindContent)
DrawSelection(ctx);
float iconWidth = IconWidth > 0f ? IconWidth : ShowLabel ? MathF.Min(32f, Height) : Width;
float iconHeight = IconHeight > 0f ? IconHeight : ShowLabel ? MathF.Min(32f, Height) : Height;
if (CatalogIconTexture != 0)
ctx.DrawSprite(CatalogIconTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
else if (SpriteResolve is not null && EmptySprite != 0)
{
var (texture, _, _) = SpriteResolve(EmptySprite);
if (texture != 0)
ctx.DrawSprite(texture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
}
if (CatalogOverlayTexture != 0)
ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
// Spell favorites are UIElement_UIItems too. Retail's
// SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0 calls the same
// SetShortcutNum path used by physical toolbar shortcuts.
DrawShortcutOverlay(ctx);
if (!SelectionBehindContent)
DrawSelection(ctx);
if (ShowLabel)
{
float labelLeft = LabelLeft > 0f ? LabelLeft : IconLeft + iconWidth + 4f;
float labelWidth = LabelWidth > 0f ? LabelWidth : MathF.Max(0f, Width - labelLeft);
if (LabelFont is not null)
{
string text = FitText(Label, labelWidth, LabelFont.MeasureWidth);
float y = MathF.Max(0f, (Height - LabelFont.LineHeight) * 0.5f);
ctx.DrawStringDat(LabelFont, text, labelLeft, y, LabelColor, Outline, OutlineColor);
}
else
{
BitmapFont? font = ctx.DefaultFont;
string text = font is null ? Label : FitText(Label, labelWidth, font.MeasureWidth);
float y = font is null ? 3f : MathF.Max(0f, (Height - font.LineHeight) * 0.5f);
ctx.DrawString(text, labelLeft, y, LabelColor, font);
}
if (!string.IsNullOrWhiteSpace(Detail))
ctx.DrawString(Detail!, MathF.Max(labelLeft, Width - 48f), 3f, Vector4.One);
}
// Drag-rollover frame last — same layering as the physical UIItem draw
// (accept/reject above selection; catalog cells have no cooldown layer).
DrawDragAcceptOverlay(ctx);
}
private void DrawSelection(UiRenderContext ctx)
{
if (!Selected || SpriteResolve is null || SelectedSprite == 0u) return;
var (texture, _, _) = SpriteResolve(SelectedSprite);
if (texture != 0u)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
private static string FitText(string text, float width, Func<string, float> measure)
{
if (width <= 0f || string.IsNullOrEmpty(text) || measure(text) <= width)
return text;
const string ellipsis = "...";
float ellipsisWidth = measure(ellipsis);
if (ellipsisWidth >= width) return string.Empty;
int low = 0, high = text.Length;
while (low < high)
{
int mid = (low + high + 1) / 2;
if (measure(text[..mid]) + ellipsisWidth <= width) low = mid;
else high = mid - 1;
}
return text[..low] + ellipsis;
}
}