CT7 gate regression (owner report): all MossTank plugin text vanished except button captions. A markup <label> authors position only, so UiLabel's box was degenerate (0x0) and CT-GF1's completed self-clip (UIRegion::DrawHere @0x0069FA30 shape) cropped its glyphs to nothing; markup buttons author w/h, which is why their captions survived. UiLabel now opts out of the self-clip — it is ClickThrough pure text whose real containment is its ancestors (the plugin panel/window, which are properly sized), the effective retail behavior for a text region whose box hugs its glyphs — and keeps a truthful box by measuring its current text each draw. Mechanism pin: an unsized label's subtree must render inside its sized parent (probe-child draw-capture test). Gate note recorded by the owner in the same round: the Titles-page divider IS visible inside the window in retail while scrolling — a retail quirk our clipped rendering now reproduces exactly. CT7 gate PASSED apart from this regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
298 lines
14 KiB
C#
298 lines
14 KiB
C#
using System;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// Rectangular container with an optional translucent background and
|
|
/// border. Used as the base of every retail panel (attributes, chat,
|
|
/// inventory, login, etc.).
|
|
///
|
|
/// Retail has panel background art stored as 9-slice sprite assets in
|
|
/// the <c>0x06xxxxxx</c> RenderSurface range, and composed via
|
|
/// <c>LayoutDesc</c> (<c>0x21xxxxxx</c>) trees. Until our
|
|
/// <c>AcFont</c>/<c>UiSpriteBatch</c> consumes those directly, we draw a
|
|
/// simple translucent rectangle so panels are visible during development.
|
|
/// </summary>
|
|
public class UiPanel : UiElement
|
|
{
|
|
/// <summary>Background fill color. Set <see cref="Vector4.Zero"/> to skip.</summary>
|
|
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.55f);
|
|
|
|
/// <summary>Border color. Set <see cref="Vector4.Zero"/> to skip.</summary>
|
|
public Vector4 BorderColor { get; set; } = new(0.15f, 0.15f, 0.2f, 0.8f);
|
|
|
|
public float BorderThickness { get; set; } = 1f;
|
|
|
|
/// <summary>Optional dat RenderSurface id for the panel background sprite, drawn
|
|
/// in place of (or alongside) <see cref="BackgroundColor"/>. 0 = none.
|
|
/// <para>CT5 fix round (SHOULD-FIX 4): drawn at NATIVE SIZE — retail's
|
|
/// copy-or-tile blit, never scaled (see <c>UiDatElement.OnDraw</c>'s own
|
|
/// doc comment, ~lines 273-360, for the full ground truth). The UV
|
|
/// rectangle below (<c>Width / tw, Height / th</c>) is deliberately
|
|
/// UV-REPEAT, not a fixed 0..1 a stretch would use — GL_REPEAT-wrapped
|
|
/// UI textures tile past their native pixel size rather than scaling.
|
|
/// Retail's generic sprite blit (<c>Graphic::Draw</c> 0x00693b20 /
|
|
/// <c>Graphic::PutImage</c> 0x00693a30) has exactly two behaviors, copy
|
|
/// or tile, and can never scale a source image up to fill a larger
|
|
/// destination.</para>
|
|
/// <para>Used by the character-panel attribute/skill row's Highlight-state
|
|
/// swap (sprite 0x06000F93, template 0x10000248's Highlight-state
|
|
/// media) and CT5's Normal-state row background (0x06004CC2). The two
|
|
/// sprites behave differently under this same tile formula: 0x06000F93
|
|
/// is authored at exactly the row's own 282x20 native size (decoded
|
|
/// against the installed DAT, 2026-08-25: PFID_R8G8B8, 282x20), so it
|
|
/// draws as a plain COPY with no visible seam; 0x06004CC2 is a 48x48
|
|
/// uniform-color tile (PFID_A8R8G8B8, single color (0,0,0,175)) that
|
|
/// visibly TILES across the wider row — both are the same code path,
|
|
/// just different source-vs-destination ratios.</para>
|
|
/// <para>Retail's own <c>UIRegion::SetImageByDID</c> (@0x0069F960)
|
|
/// confirms its third parameter is a <c>BlitMode</c> COLOR-BLEND
|
|
/// selector, not a resize flag — the decompiled body switches purely on
|
|
/// that value (<c>param_2 == 2</c> -> <c>Blit_3Alpha</c>,
|
|
/// <c>== 3</c> -> <c>Blit_4Alpha</c>, else <c>Blit_Normal</c>) and
|
|
/// never touches width/height at all. This answers CT1's open "icon
|
|
/// draw mode 3" question (ground-truth doc §2): mode 3 selects
|
|
/// <c>Blit_4Alpha</c>, an alpha-blend variant, not a resize.</para>
|
|
/// </summary>
|
|
public uint BackgroundSprite { get; set; }
|
|
|
|
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
|
|
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
|
|
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
|
|
|
protected override void OnDraw(UiRenderContext ctx)
|
|
{
|
|
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
|
|
{
|
|
var (tex, tw, th) = sr(BackgroundSprite);
|
|
if (tex != 0 && tw != 0 && th != 0)
|
|
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
|
}
|
|
else if (BackgroundColor.W > 0f)
|
|
{
|
|
// Panel fills are backgrounds. Draw them through the sprite/fill
|
|
// bucket so children that render as sprites/dat-font glyphs stay on
|
|
// top in painter order; DrawRect flushes after sprites and would
|
|
// cover text/icons.
|
|
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
|
}
|
|
|
|
if (BorderColor.W > 0f && BorderThickness > 0f)
|
|
ctx.DrawRectOutline(0, 0, Width, Height, BorderColor, BorderThickness);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Static text label. Draws a single line of text using the context's
|
|
/// default font (or an override). Does not consume input.
|
|
///
|
|
/// Equivalent retail primitive: wide-string appended to a CString via
|
|
/// <c>FUN_0040b8f0</c> then drawn by the widget's draw method through
|
|
/// <c>FUN_00698330</c>.
|
|
/// </summary>
|
|
public class UiLabel : UiElement
|
|
{
|
|
public string Text { get; set; } = string.Empty;
|
|
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
|
|
|
|
/// <summary>
|
|
/// Optional live text reader, evaluated each draw and preferred over
|
|
/// <see cref="Text"/> when set. Markup <c>{Binding}</c> labels use this so
|
|
/// the displayed value tracks the binding object instead of freezing at
|
|
/// whatever it held when the panel was built — which is what a plugin
|
|
/// status line needs.
|
|
/// </summary>
|
|
public Func<string?>? TextSource { get; set; }
|
|
|
|
/// <summary>
|
|
/// Retail dat font. When set the label renders through the same glyph path
|
|
/// every authored panel uses, so plugin text matches the rest of the
|
|
/// interface instead of falling back to the development bitmap font.
|
|
/// </summary>
|
|
public UiDatFont? DatFont { get; set; }
|
|
|
|
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
|
|
public bool Outline { get; set; } = true;
|
|
|
|
public UiLabel() { ClickThrough = true; }
|
|
|
|
/// <summary>
|
|
/// CT7 gate regression (2026-08-25, MossTank plugin text vanished): a
|
|
/// markup <c><label></c> authors position only, so this element's
|
|
/// box is degenerate (0x0) — under CT-GF1's completed
|
|
/// <c>UIRegion::DrawHere @0x0069FA30</c> port the self-clip cropped the
|
|
/// glyphs to nothing. A label must not self-clip: it is
|
|
/// <see cref="UiElement.ClickThrough"/> pure text whose real clip is
|
|
/// its ancestors (the plugin panel/window, which ARE properly sized) —
|
|
/// the same effective containment retail gives a text region whose box
|
|
/// hugs its glyphs. <see cref="OnDraw"/> below also keeps the box
|
|
/// truthful by measuring the current text, so layout/hit consumers see
|
|
/// real extents (converges the frame after a bound text change).
|
|
/// </summary>
|
|
protected override bool ClipsChildren => false;
|
|
|
|
protected override void OnDraw(UiRenderContext ctx)
|
|
{
|
|
string text = TextSource?.Invoke() ?? Text;
|
|
float w = DatFont is { } df
|
|
? df.MeasureWidth(text)
|
|
: (ctx.DefaultFont?.MeasureWidth(text) ?? text.Length * 7f);
|
|
float h = DatFont?.LineHeight
|
|
?? ctx.DefaultFont?.LineHeight ?? 14f;
|
|
if (w != Width) Width = w;
|
|
if (h != Height) Height = h;
|
|
if (DatFont is { } dat)
|
|
ctx.DrawStringDat(dat, text, 0, 0, TextColor, Outline);
|
|
else
|
|
ctx.DrawString(text, 0, 0, TextColor);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simple clickable button: panel background + centered label + click
|
|
/// callback. Retail equivalent is Keystone's button widget, driven by
|
|
/// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed /
|
|
/// disabled) from the panel layout.
|
|
/// Note: the dat-widget button (Type 1 / UIElement_Button) is <see cref="AcDream.App.UI.UiButton"/>
|
|
/// in <c>UiButton.cs</c> — that is the production widget used by D.2b panels.
|
|
/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites).
|
|
/// </summary>
|
|
public class UiSimpleButton : UiPanel
|
|
{
|
|
public string Text { get; set; } = string.Empty;
|
|
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
|
|
|
|
/// <summary>
|
|
/// Optional live caption reader, preferred over <see cref="Text"/> when
|
|
/// set, so a markup-bound button can change its own label (Buff / Stop)
|
|
/// without the binding object touching UI objects.
|
|
/// </summary>
|
|
public Func<string?>? TextSource { get; set; }
|
|
|
|
/// <summary>Retail dat font for the caption; see <see cref="UiLabel.DatFont"/>.</summary>
|
|
public UiDatFont? DatFont { get; set; }
|
|
|
|
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
|
|
public bool Outline { get; set; } = true;
|
|
|
|
public event System.Action? Click;
|
|
|
|
/// <summary>
|
|
/// Without this the button is unclickable inside any draggable window.
|
|
/// UiRoot's press handling asks the pressed widget whether it owns the
|
|
/// pointer; a widget that does not claim the press falls through to
|
|
/// "move the ancestor window", which swallows the release and never emits
|
|
/// a Click. <see cref="UiButton"/> and <see cref="UiClickablePanel"/>
|
|
/// already declare it — this one did not, which is why a markup plugin
|
|
/// panel's button did nothing while its hit-test was perfectly fine.
|
|
/// </summary>
|
|
public override bool HandlesClick => true;
|
|
|
|
public UiSimpleButton()
|
|
{
|
|
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
|
|
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);
|
|
}
|
|
|
|
public override bool OnEvent(in UiEvent e)
|
|
{
|
|
if (e.Type == UiEventType.Click && Enabled)
|
|
{
|
|
Click?.Invoke();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
protected override void OnDraw(UiRenderContext ctx)
|
|
{
|
|
base.OnDraw(ctx);
|
|
string caption = TextSource?.Invoke() ?? Text;
|
|
if (caption.Length == 0) return;
|
|
|
|
if (DatFont is { } dat)
|
|
{
|
|
float datW = dat.MeasureWidth(caption);
|
|
ctx.DrawStringDat(
|
|
dat, caption,
|
|
(Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f,
|
|
TextColor, Outline);
|
|
return;
|
|
}
|
|
|
|
if (ctx.DefaultFont is null) return;
|
|
float textW = ctx.DefaultFont.MeasureWidth(caption);
|
|
float tx = (Width - textW) * 0.5f;
|
|
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
|
|
ctx.DrawString(caption, tx, ty, TextColor);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A <see cref="UiPanel"/> that fires an <see cref="OnClick"/> callback when the user
|
|
/// left-clicks it. Used for the attribute-list rows in the Character window — each row
|
|
/// is a transparent container that needs to respond to pointer hits while its children
|
|
/// (icon, name, value) are ClickThrough decorations.
|
|
///
|
|
/// <para>Retail analog: the <c>AttributeInfoRegion</c> row widget in <c>gmAttributeUI</c>
|
|
/// catches <c>UIEvent_LeftClick</c> (0x01) and calls <c>SetSelectedAttribute</c> on the
|
|
/// parent window. In acdream we wire the equivalent via this action callback instead of
|
|
/// the retail message bus.</para>
|
|
///
|
|
/// <para>Campaign CT slice CT5 (2026-08-25): the selected-row highlight draws through the
|
|
/// inherited <see cref="UiPanel.OnDraw"/> full-panel <see cref="UiPanel.BackgroundSprite"/>
|
|
/// draw (native-size copy-or-tile — see that property's own doc comment for the CT5 fix
|
|
/// round's correction of the mechanism; NOT a stretch) — no override here. This class
|
|
/// previously had its own "selection bars" draw mode (a thin top/bottom-bar rendering
|
|
/// tuned to look like sprite 0x06001397's dark bars); CT1's ground-truth research found
|
|
/// that sprite belongs to a DIFFERENT retail mechanism entirely (the spellbook row's
|
|
/// overlay child), and the row's actual retail Highlight state (<c>InfoRegion::SetState</c>,
|
|
/// media 0x06000F93) is a plain full-row background SWAP — exactly what the inherited
|
|
/// <see cref="UiPanel.OnDraw"/> already draws (0x06000F93 is authored at exactly the row's
|
|
/// own 282x20 native size, so the swap needs no scaling to look right). The bars mode was
|
|
/// therefore retired rather than reconfigured to a wrong sprite's geometry; no consumer
|
|
/// outside <c>CharacterStatController</c> ever set it.</para>
|
|
/// </summary>
|
|
public class UiClickablePanel : UiPanel
|
|
{
|
|
/// <summary>Called when the user releases the left mouse button over this panel.</summary>
|
|
public Action? OnClick { get; set; }
|
|
|
|
/// <summary>Settable tooltip, surfaced through the shared
|
|
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
|
|
/// <see cref="UiButton.TooltipText"/> / <see cref="UiCatalogSlot"/>). TS-85's
|
|
/// character-panel gap: retail's <c>AttributeInfoRegion</c> /
|
|
/// <c>Attribute2ndInfoRegion</c> / <c>SkillInfoRegion</c> row constructors
|
|
/// (<c>UIElement::SetTooltip</c> at 0x004f1617 / 0x004f1777 / 0x004f222f) stamp
|
|
/// this once per row at construction — retail never updates it afterward, so a
|
|
/// plain settable string (not a live provider) matches.</summary>
|
|
public string? TooltipText { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
public override string? GetTooltipText() =>
|
|
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
|
|
|
|
public UiClickablePanel()
|
|
{
|
|
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
|
|
// which is the UiElement base default). Explicit for clarity.
|
|
ClickThrough = false;
|
|
}
|
|
|
|
/// <summary>HandlesClick = true ensures this row receives its own Click even when
|
|
/// it is nested inside a Draggable ancestor window frame (e.g. the future character
|
|
/// window with a whole-window drag handle). Without this, the press would be consumed
|
|
/// by the ancestor's drag logic and the Click would never fire.</summary>
|
|
public override bool HandlesClick => true;
|
|
|
|
public override bool OnEvent(in UiEvent e)
|
|
{
|
|
if (e.Type == UiEventType.Click && Enabled)
|
|
{
|
|
OnClick?.Invoke();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|