acdream/src/AcDream.App/UI/UiRenderContext.cs
Erik 025108a8aa fix(CT-GF1): review fix round — literal DrawHere clip shape, empty-clip cull, popup input routing
Applies all 11 items from the Opus dual-lens review of 989f6652 (0
blockers, 7 SHOULD-FIX, 4 NOTE):

- S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right
  after PushAlpha and wraps OnDraw + the children walk +
  OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere
  @0x0069FA30 shape, which clips an element's OWN DrawSelf too, not
  just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph
  blits to its own clipped surface rect; UIRegion::DrawSelf
  @0x0069F1A0 blits per clip rect). Deleted the two now-redundant
  ad-hoc self-clips this supersedes: UiText.DrawText and
  UiField.DrawMultiLine both pushed their own (0,0,Width,Height) —
  exactly what the new ambient clip already provides one level up.
  Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox,
  an authored INNER sub-rect that can be smaller than and offset from
  the button's own full rect — a genuine narrower viewport, not a
  redundant duplicate.
- S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override
  — correct under the old opt-in-false default, inverted under the
  new default-true (an unconfigured list would stop clipping instead
  of clipping like everything else).
- S4: pinned the escaped-popup input path end to end. New
  UiAncestorClipTests test mounts a menu inside a short window on a
  real UiRoot, opens it, and proves a click in the escaped popup
  region reaches the menu through UiRoot.PopupHit (a plain top-down
  walk is proven to reject the same point first). UiRoot.WantsMouse
  now also checks PopupHit — it previously only checked Captured/
  HitTestTopDown, so a game action could fire underneath an open
  dropdown's escaped region. OnMouseDown/OnScroll already routed
  through PopupHit first (#374); unchanged.
- S5: strengthened the Titles-divider regression test's positive
  half. The old assertion only checked SOME quad's Y fell in a band —
  vacuously true given other same-band content. Now asserts the
  divider's exact rect (X and Y), then diffs against the same rect
  with the divider hidden (Visible=false) to prove the quad was
  actually attributable to it.
- S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/
  Options mounted through their real production Bind entry points
  with a non-zero sprite resolver, drawn via RecordingGpuDevice,
  asserting a per-window vertex floor (~40-45% of this session's
  observed baseline: Character 588, Chat 162, Vendor 54, Options 240)
  plus one key sprite id read LIVE off the bound controller/element
  (never hardcoded). Character's key sprite (RetailChromeSprites.
  TopEdge) specifically exercises OnDrawAfterChildren, the exact path
  S2's caution note flagged. Inventory/Paperdoll/social/map-house
  skipped — no single fixture-driven top-level Bind entry point.
- S6: added the CT-GF1 subsection to the campaign plan's ledger
  (989f6652 + this fix round; CT7 re-gate still owed).
- S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect
  (0,0,ScreenSize), not null — retail's own popup region is
  SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region
  bounded by the screen), not truly unbounded. AD-113 amended.
- N1: UiRoot overrides ClipsChildren => false — the root's own region
  IS the screen (the viewport already scissors it), so this is a
  safety net against a momentarily zero-sized root silently blanking
  the whole UI tree under the new ancestor-clip default.
- N2: added the empty-clip subtree cull (retail's var_24 gate
  @0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly
  separate traversal untouched by this change. New test proves a menu
  inside a fully-clipped (zero-width) window still draws its open
  popup via the overlay pass while the main pass draws nothing.
- N3: CT7 script §5 now names the collapsed-toolbar check and the
  four highest-overflow windows (combat/vitals bar, Options
  bottom-button row, map/house page, floaty chat) as explicit
  eyeball items for the re-gate.
- N4: verification below covers both the working tree and the clean
  committed tree.

Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate
@0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip);
UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen-
clipped popup region).

Verification (both runs green, --filter "Lane!=InstalledDat&
Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&
Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&
Status!=KnownFailure"): full Release solution build green; working
tree 14,900+ tests across every project (one LandblockPresentation
PipelineTests flake reproduced ONLY under full-solution parallel
load, passes standalone and on rerun — unrelated to this change,
streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT
_TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core
tests). Clean committed tree (git stash push -u the uncommitted
owner probe + docs files, rerun, stash pop) reported in the session
summary.

src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively
(git add -p) so only this commit's own two hunks (ClipsChildren
override, WantsMouse) landed; the probe hunk is untouched and stays
uncommitted, same as before this fix round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:11:42 +02:00

510 lines
25 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
internal readonly record struct UiClipRect(float Left, float Top, float Right, float Bottom)
{
public bool IsEmpty => Right <= Left || Bottom <= Top;
public static UiClipRect Intersect(UiClipRect a, UiClipRect b)
=> new(
MathF.Max(a.Left, b.Left),
MathF.Max(a.Top, b.Top),
MathF.Min(a.Right, b.Right),
MathF.Min(a.Bottom, b.Bottom));
public static bool TryClipSprite(
UiClipRect clip,
ref float x, ref float y, ref float w, ref float h,
ref float u0, ref float v0, ref float u1, ref float v1)
=> QuadClipper.TryClip(
clip.Left, clip.Top, clip.Right, clip.Bottom,
ref x, ref y, ref w, ref h,
ref u0, ref v0, ref u1, ref v1);
}
/// <summary>
/// Per-frame drawing context passed through the <see cref="UiElement"/>
/// tree. Wraps a <see cref="TextRenderer"/> (our 2D sprite batcher) and a
/// transform stack so elements can draw in local coordinates.
///
/// Retail equivalent: the implicit context <c>FUN_005da8f0</c> walks with
/// when iterating the UI tree. Our version is explicit so it plugs
/// cleanly into Silk.NET.
/// </summary>
public sealed class UiRenderContext
{
public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; }
public Vector2 ScreenSize { get; }
// Transform stack — simple 2D translate (no rotation/scale for UI).
private readonly System.Collections.Generic.List<Vector2> _stack = new();
private Vector2 _current;
private readonly System.Collections.Generic.List<UiClipRect?> _clipStack = new();
private UiClipRect? _clip;
// Alpha (opacity) stack — a window pushes its Opacity so EVERY draw under it
// (sprite, rect/fill, AND text) fades together. Retail's ChatInterface::SetOpacity
// (0x004F3120) sets one alpha on the window's whole composited render surface —
// chrome, background, and glyphs all fade as one unit, not text-stays-sharp over a
// translucent panel. Campaign CH slice CH6c ported this: DrawStringDat and
// DrawString both route through ApplyAlpha exactly like DrawSprite/DrawRect/DrawFill.
private readonly System.Collections.Generic.List<float> _alphaStack = new();
private float _alpha = 1f;
/// <summary>Current cumulative opacity multiplier applied to sprite + rect draws.</summary>
public float AlphaMod => _alpha;
/// <summary>Multiply <paramref name="a"/> into the running opacity. Pair with <see cref="PopAlpha"/>.</summary>
public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; }
/// <summary>Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays
/// that must stay opaque even inside a translucent window. Pair with <see cref="PopAlpha"/>.</summary>
public void PushAlphaAbsolute(float a) { _alphaStack.Add(_alpha); _alpha = a; }
public void PopAlpha()
{
if (_alphaStack.Count == 0) return;
_alpha = _alphaStack[^1];
_alphaStack.RemoveAt(_alphaStack.Count - 1);
}
public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null)
{
TextRenderer = tr;
ScreenSize = screenSize;
DefaultFont = defaultFont;
}
/// <summary>Push a relative translate. Must be paired with <see cref="PopTransform"/>.</summary>
public void PushTransform(float dx, float dy)
{
_stack.Add(_current);
_current += new Vector2(dx, dy);
}
public void PopTransform()
{
if (_stack.Count == 0) return;
_current = _stack[^1];
_stack.RemoveAt(_stack.Count - 1);
}
public Vector2 CurrentOrigin => _current;
/// <summary>Intersect descendant drawing with a local-space viewport.</summary>
public void PushClip(float x, float y, float w, float h)
{
_clipStack.Add(_clip);
var next = new UiClipRect(
_current.X + x,
_current.Y + y,
_current.X + x + MathF.Max(0f, w),
_current.Y + y + MathF.Max(0f, h));
_clip = _clip is { } current
? UiClipRect.Intersect(current, next)
: next;
}
public void PopClip()
{
if (_clipStack.Count == 0) return;
_clip = _clipStack[^1];
_clipStack.RemoveAt(_clipStack.Count - 1);
}
/// <summary>
/// True when the current accumulated clip is non-null and has zero (or negative)
/// area — CT-GF1 fix-round subtree cull, porting retail's
/// <c>UIRegion::DrawHere</c> <c>var_24</c> gate @0x0069FB8E: an empty intersected
/// clip skips <c>EraseSelf</c>/<c>DrawChildren</c>/<c>DrawSelf</c> for the whole
/// subtree, not just individual draw calls (those already no-op against an empty
/// clip via <see cref="ClipRect"/>/<see cref="UiClipRect.TryClipSprite"/> — this
/// additionally skips the WALK). A null clip (nothing pushed yet, or reset via
/// <see cref="PushClipUnbounded"/>) is NOT empty — it means unbounded, so this is
/// false in that case.
/// </summary>
public bool CurrentClipIsEmpty => _clip is { } c && c.IsEmpty;
/// <summary>
/// Reset the accumulated clip to the full CANVAS rect (0,0,ScreenSize) for the
/// duration of one overlay draw — the escape hatch <see cref="UiElement.ExpandsClipForPopup"/>
/// uses so a popup drawn inline from its owning widget (see that property's doc
/// comment for the retail-parity rationale) is not wrongly clipped by the
/// ancestor chain the CT-GF1 default clip (<see cref="UiElement.ClipsChildren"/>)
/// now threads through every other element. Retail's own popup region is still
/// SCREEN-clipped (<c>UIElement_Menu::MakePopup</c> spawns a top-level region
/// bounded by the screen, not truly infinite) — this is the canvas rect, not
/// <c>null</c>/unbounded, matching that. Shares <see cref="PopClip"/>'s stack, so
/// pair the two exactly like <see cref="PushClip"/>.
/// </summary>
public void PushClipUnbounded()
{
_clipStack.Add(_clip);
_clip = new UiClipRect(0f, 0f, ScreenSize.X, ScreenSize.Y);
}
/// <summary>Route subsequent draws to the overlay layer (flushed on top of the whole
/// UI). Used by the root for the popup/overlay traversal. Pair with <see cref="EndOverlayLayer"/>.</summary>
public void BeginOverlayLayer() => TextRenderer.OverlayMode = true;
public void EndOverlayLayer() => TextRenderer.OverlayMode = false;
// ── Pass-through draw helpers (add current translate) ──────────────
public void DrawRect(float x, float y, float w, float h, Vector4 color)
{
x += _current.X;
y += _current.Y;
if (!ClipRect(ref x, ref y, ref w, ref h)) return;
TextRenderer.DrawRect(x, y, w, h, ApplyAlpha(color));
}
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
/// all sprites and would cover the text — use this for backgrounds, that for foreground
/// fills (carets, vital bars).</summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
{
x += _current.X;
y += _current.Y;
if (!ClipRect(ref x, ref y, ref w, ref h)) return;
TextRenderer.DrawFill(x, y, w, h, ApplyAlpha(color));
}
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
if (thickness <= 0f || w <= 0f || h <= 0f) return;
float t = MathF.Min(thickness, MathF.Min(w, h) * 0.5f);
DrawRect(x, y, w, t, color);
DrawRect(x, y + h - t, w, t, color);
DrawRect(x, y + t, t, h - 2f * t, color);
DrawRect(x + w - t, y + t, t, h - 2f * t, color);
}
public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint)
{
x += _current.X;
y += _current.Y;
DrawSpriteAbsolute(texture, x, y, w, h, u0, v0, u1, v1, tint, applyAlpha: true);
}
private void DrawSpriteAbsolute(
uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint, bool applyAlpha)
{
if (_clip is { } clip
&& !UiClipRect.TryClipSprite(
clip, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1))
return;
TextRenderer.DrawSprite(
texture, x, y, w, h, u0, v0, u1, v1,
applyAlpha ? ApplyAlpha(tint) : tint);
}
private bool ClipRect(ref float x, ref float y, ref float w, ref float h)
{
if (_clip is not { } clip)
return w > 0f && h > 0f;
float u0 = 0f, v0 = 0f, u1 = 1f, v1 = 1f;
return UiClipRect.TryClipSprite(
clip, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1);
}
/// <summary>Multiply the current window opacity into a draw color's alpha.</summary>
private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha);
public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null)
{
var f = font ?? DefaultFont;
if (f is null) return;
float screenX = _current.X + x;
float screenY = _current.Y + y;
Vector4 alphaColor = ApplyAlpha(color);
if (_clip is { } clip)
{
TextRenderer.DrawStringClipped(
f, text, screenX, screenY, alphaColor,
clip.Left, clip.Top, clip.Right, clip.Bottom);
return;
}
TextRenderer.DrawString(f, text, screenX, screenY, alphaColor);
}
/// <summary>Retail <c>UIElement_Text</c>'s constructor outline color default
/// (<c>RGBAColor_Black</c>, <c>UIElement_Text::UIElement_Text @0x004686cb</c>).
/// Used by <see cref="DrawStringDat"/> when the caller doesn't supply an
/// explicit <c>outlineColor</c> (LayoutDesc property 0x22 is authored on only
/// 9 elements in the whole DAT set — every other outlined element uses this).</summary>
public static readonly Vector4 DefaultOutlineColor = new(0f, 0f, 0f, 1f);
/// <summary>
/// AD-78 (user-directed, 2026-08-11, Campaign OP gate 2): the shared caption
/// color for Options-panel / Configure-Keyboard rows that are store-only —
/// they persist (and, where auto-save, send the wire bit) but drive nothing
/// observable in acdream yet. Retail dims nothing here (every retail row has
/// a live consumer by construction); this is a deliberate acdream-only
/// convention so a store-only row is visually distinguishable from a live
/// one without any invented marker text. Same neutral grey as the existing
/// disabled/ghosted convention (<see cref="UiMenu.TextColorGhosted"/>,
/// retail's disabled StateDesc grey) — reused rather than a new color so the
/// "this doesn't do anything yet" signal reads consistently across the whole
/// UI. See AD-78 for the full citation list; the row retires as consumers
/// land (each landing un-dims its own rows via the owning controller's
/// conformance test).
/// </summary>
public static readonly Vector4 StoreOnlyCaptionColor = new(0.5f, 0.5f, 0.5f, 1f);
/// <summary>
/// Draw a single line of text with a retail dat font (<see cref="UiDatFont"/>),
/// at <paramref name="x"/>,<paramref name="y"/> = the top-left of the
/// typographic block (in this element's local space). The pen advances by
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each
/// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis
/// and at <c>baseline + VerticalOffsetBefore</c> via the glyph's OffsetY into
/// the atlas.
///
/// <para>
/// <b>Two-pass outline model</b> — <c>UIElement_Text::DrawSelf</c>
/// (acclient 0x00467aa0): <c>var_b0 = (m_bitField &amp; 0x10) ? 0 : 1;</c> — when
/// the outline bit is CLEAR the loop runs once (fill only, <c>var_b0==1</c>);
/// when SET it runs twice, pass 0 (outline) for the WHOLE STRING first, then
/// pass 1 (fill) for the whole string. Fills therefore always paint over every
/// neighbour's outline — this method reproduces that by iterating the string
/// twice rather than interleaving outline+fill per glyph, so on tight kerning
/// glyph N+1's outline never covers glyph N's fill (nor vice versa).
/// </para>
///
/// <para>
/// <paramref name="outline"/> gates the outline passes. Retail decides this PER
/// text element: the bit is <c>m_bitField &amp; 0x10</c>, set by LayoutDesc
/// property <b>0x21</b> (<c>SetOutline @0x0046a81c</c>) — NOT property 0xd (an
/// earlier comment here named the wrong id: 0xd is the *switch-case index* inside
/// <c>UIElement_Text::OnSetAttribute</c>, not the authored property). The DEFAULT
/// is OFF (one fill-only pass, ctor <c>m_bitField=0x300</c> clears bit 0x10):
/// outlining is opt-in per element (~100 authored rows across 15 layouts).
/// <paramref name="outlineColor"/> is the element's <c>m_curOutlineColor</c>
/// (LayoutDesc property 0x22, ctor default <see cref="DefaultOutlineColor"/> —
/// black; only 9 elements in the whole DAT set author a non-black value).
/// </para>
///
/// <para>
/// <b>Outline mechanism</b> — retail picks by data, per font
/// (<c>feedback_retail_dispatch_is_data_driven</c> shape): when
/// <see cref="UiDatFont.HasBackground"/> is true, the outline pass blits the
/// BACKGROUND (dilated) atlas sub-rect, inflated by
/// <see cref="UiDatFont.BorderX"/>/<see cref="UiDatFont.BorderY"/> on every
/// side of both the source AND destination rect
/// (<c>SurfaceWindow::DrawCharacter @0x00442d3a</c> +
/// <c>CreateCharRectPair @0x00441480</c>) — the un-inflated rect crops the
/// dilation away, making the outline invisible even with the flag on. When the
/// font has NO background atlas (the CJK/unicode family), retail falls back to
/// 8 neighbour blits of the FOREGROUND glyph at ±1px
/// (<c>UIElement_Text::DrawSelf</c> 0x00467d7e-0x00467e14).
/// </para>
/// </summary>
public void DrawStringDat(
UiDatFont font, string text, float x, float y, Vector4 color,
bool outline = false, Vector4? outlineColor = null)
{
if (font is null || string.IsNullOrEmpty(text)) return;
if (outline)
{
// PASS 0 — outline, whole string (acclient 0x00467aa0, var_b0 starts at 0).
// The outline tint uses the outline color's OWN alpha (round-5 review N3):
// an earlier version substituted the FILL color's alpha here, which is not
// what retail's DrawSelf does (the outline and fill passes tint independent
// RGBA colors, m_curOutlineColor and m_curTextColor) and made a translucent
// outline color always render at the fill's opacity instead of its own.
DrawStringDatPass(font, text, x, y, outlineColor ?? DefaultOutlineColor, isOutlinePass: true);
}
// PASS 1 (or the only pass, when outline is off) — fill, whole string.
DrawStringDatPass(font, text, x, y, color, isOutlinePass: false);
}
/// <summary>
/// One whole-string pass of <see cref="DrawStringDat"/>'s two-pass outline+fill
/// model: either every glyph's outline (<paramref name="isOutlinePass"/> true) or
/// every glyph's fill. Recomputes the pen from scratch — a pure function of
/// <paramref name="font"/>/<paramref name="text"/>/<paramref name="x"/>/
/// <paramref name="y"/>, so repeated calls advance identically and stay in
/// lock-step without sharing mutable state.
///
/// <para>
/// <b>Exposed for BLOCK-level batching</b> (round-5 review S1): a caller that draws
/// several lines or runs sharing ONE <c>Outline</c> flag (<see cref="AcDream.App.UI.UiText"/>'s
/// multi-line transcript and colored-run label) must submit EVERY line's outline
/// pass before ANY line's fill pass — retail's <c>UIElement_Text::DrawSelf</c>
/// (0x00467aa0) walks every glyph of the whole block in the outline pass before any
/// fill. Calling <see cref="DrawStringDat"/> once per line instead (outline+fill,
/// outline+fill, ...) lets line N+1's outline draw AFTER line N's fill and notch a
/// descender that pokes into the line above. A caller with several lines should
/// call this once per line with <paramref name="isOutlinePass"/>=true for every
/// line, THEN once per line with <paramref name="isOutlinePass"/>=false for every
/// line. A single independent line can still call <see cref="DrawStringDat"/> directly.
/// </para>
/// </summary>
public void DrawStringDatPass(
UiDatFont font, string text, float x, float y, Vector4 tint, bool isOutlinePass)
{
if (font is null || string.IsNullOrEmpty(text)) return;
// Baseline of this line in local space; retail draws glyphs whose
// descriptor OffsetY already places them relative to the line top, so we
// anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore.
float originX = _current.X + x;
float originY = _current.Y + y;
// Snap the LINE baseline to a whole pixel ONCE. Retail's
// SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y
// (arg3) and adds the glyph's integer m_VerticalOffsetBefore (a schar) — every
// glyph on a line shares one integer baseline. If we instead round EACH glyph's
// Y independently and the caller passes a fractional line Y (e.g. a channel-menu
// item centered in a 17px row over a 16px font → y = 0.5), adjacent letters round
// to different rows and the line looks crooked ("letters dip down"). The vitals
// digits never showed it because their bar baseline lands on an integer; chat text
// does. Snapping the baseline once, then adding the integer offset, keeps the whole
// line on one row and pixel-aligned.
//
// HALF-UP, not MathF.Round (2026-08-24 owner report: window-title and
// button labels "vibrate" while dragging a window): MathF.Round is
// banker's rounding — a centered label whose origin carries a constant
// .5 fraction (odd text width over /2) alternates round-up/round-down
// as the dragged window crosses successive integers, so the text
// double-steps then sticks while the background glides 1px per frame.
// Floor(v + 0.5) snaps every tie the same direction: constant fraction
// → uniform 1px steps in lock-step with the sprites.
float baseY = System.MathF.Floor(originY + 0.5f);
float pen = originX;
for (int i = 0; i < text.Length; i++)
{
if (!font.TryGetGlyph(text[i], out var g))
continue;
// Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
// true fractional advance). Vertical: integer baseline + integer per-glyph
// offset — never an independent per-glyph round (see baseY's note above).
// Half-up for the same anti-vibration reason as baseY.
float gx = System.MathF.Floor(pen + g.HorizontalOffsetBefore + 0.5f);
float gy = baseY + g.VerticalOffsetBefore;
float gw = g.Width;
float gh = g.Height;
if (gw > 0f && gh > 0f)
{
if (isOutlinePass)
DrawOutlineGlyph(font, g, gx, gy, gw, gh, tint);
else
DrawFillGlyph(font, g, gx, gy, gw, gh, tint);
}
pen += UiDatFont.GlyphAdvance(g);
}
}
/// <summary>Foreground (fill) atlas blit, tinted with the requested text color.
/// Both passes route through <c>DrawSpriteAbsolute(..., applyAlpha: true)</c> so a
/// window's opacity fades glyphs exactly like its chrome/background sprites —
/// retail's <c>ChatInterface::SetOpacity</c> (0x004F3120) fades the whole
/// composited surface.</summary>
private void DrawFillGlyph(
UiDatFont font, DatReaderWriter.Types.FontCharDesc g,
float gx, float gy, float gw, float gh, Vector4 tint)
{
var (fu0, fv0, fu1, fv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.ForegroundWidth, font.ForegroundHeight);
DrawSpriteAbsolute(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, tint, applyAlpha: true);
}
/// <summary>Outline-pass blit for one glyph: the background (dilated) atlas
/// sub-rect inflated by the font's border-pixel margin when the font carries
/// one, else retail's 8-neighbour ±1px foreground-glyph fallback.</summary>
private void DrawOutlineGlyph(
UiDatFont font, DatReaderWriter.Types.FontCharDesc g,
float gx, float gy, float gw, float gh, Vector4 tint)
{
if (font.BackgroundTexture != 0)
{
// Background (dilated) plane, inflated by (BorderX, BorderY) on every side of
// BOTH the source sub-rect and the destination rect — CreateCharRectPair's
// background call (0x00442d3a) inflates both symmetrically before the atlas
// blit. The un-inflated rect (acdream's prior behavior) crops the dilation
// away entirely, leaving only a single stray pixel where a descender pokes out.
int bx = font.BorderX, by = font.BorderY;
float ix = gx - bx;
float iy = gy - by;
float iw = gw + 2f * bx;
float ih = gh + 2f * by;
// Clamp the inflated SOURCE rect to the atlas bounds (round-5 review N4):
// a glyph whose art sits at the edge of the background atlas can inflate
// past [0, BackgroundWidth/Height], which produced UV coordinates outside
// [0,1] and could sample a neighbouring glyph's texels. Retail's own
// CreateCharRectPair (0x00441480) clamps both rects the same way — when a
// side of the SOURCE rect would fall outside the atlas, that side is pulled
// back to the edge and the DESTINATION rect shrinks by the same amount, so
// the visible outline is simply cropped rather than mis-sampled. A no-op
// (byte-identical output) for every glyph that doesn't sit on the atlas edge.
float srcX = g.OffsetX - bx, srcY = g.OffsetY - by;
float srcW = iw, srcH = ih;
float destX = ix, destY = iy, destW = iw, destH = ih;
if (srcX < 0f) { destX -= srcX; destW += srcX; srcW += srcX; srcX = 0f; }
if (srcY < 0f) { destY -= srcY; destH += srcY; srcH += srcY; srcY = 0f; }
if (srcX + srcW > font.BackgroundWidth)
{
float over = srcX + srcW - font.BackgroundWidth;
srcW -= over; destW -= over;
}
if (srcY + srcH > font.BackgroundHeight)
{
float over = srcY + srcH - font.BackgroundHeight;
srcH -= over; destH -= over;
}
if (srcW <= 0f || srcH <= 0f) return;
var (bu0, bv0, bu1, bv1) = AtlasUv(
(int)srcX, (int)srcY, (int)srcW, (int)srcH,
font.BackgroundWidth, font.BackgroundHeight);
DrawSpriteAbsolute(font.BackgroundTexture, destX, destY, destW, destH, bu0, bv0, bu1, bv1, tint, applyAlpha: true);
}
else
{
// No background plane (retail's 0-border fonts — the CJK/unicode family):
// fall back to retail's 8-neighbour ±1px foreground-glyph blit
// (UIElement_Text::DrawSelf 0x00467d7e-0x00467e14).
var (fu0, fv0, fu1, fv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.ForegroundWidth, font.ForegroundHeight);
for (int dy = -1; dy <= 1; dy++)
{
for (int dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0) continue;
DrawSpriteAbsolute(
font.ForegroundTexture, gx + dx, gy + dy, gw, gh,
fu0, fv0, fu1, fv1, tint, applyAlpha: true);
}
}
}
}
/// <summary>Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to
/// normalized UVs for an atlas of <paramref name="atlasW"/> x
/// <paramref name="atlasH"/>. Guards against a zero-sized atlas.</summary>
private static (float u0, float v0, float u1, float v1) AtlasUv(
int offsetX, int offsetY, int width, int height, int atlasW, int atlasH)
{
if (atlasW <= 0 || atlasH <= 0) return (0f, 0f, 0f, 0f);
float u0 = offsetX / (float)atlasW;
float v0 = offsetY / (float)atlasH;
float u1 = (offsetX + width) / (float)atlasW;
float v1 = (offsetY + height) / (float)atlasH;
return (u0, v0, u1, v1);
}
}