Four of the five owner-reported chat deltas (2026-08-24), each traced to
its retail mechanism:
1. Missing gold separator left of the input: the chat input authors two
1px Type-3 rail CHILDREN (0x10000017 at X=0, 0x10000018
right-anchored) whose only media is Normal_focussed (0x06004D67,
live-DAT probed). UiField consumes its DAT children, so the rails
were swallowed and never drawn. The factory now folds them into the
field, which draws both while focused.
2. Button says "General", retail says "Gen": the talk button's short
caption comes from per-target ID_Chat_ChatTargetMenu* strings
(HandleSelection @0x004cd540, StringTable 0x23000001 via
compute_str_hash — recovered from the raw binary after BN elided the
ids into name-hash globals). Authored set: Chat/Tell/Fell/Pat/Mon/
Vas/Alg/Gen/Trade/LFG/RP/Soc/Olt. Menu rows + squelch/tell specials
resolve from the same table (ID_Chat_TellTo*); production resolves
through DatStringResolver, fallbacks ARE the authored EoR English.
ChatStringsLiveDatTests pins the whole set against the installed DAT.
3. Channel button stayed green while the popup was open: retail's
pressed face is the momentary physical press ("flicks"); the OPEN
state drives only the arrow-cap child's StateDesc swap
(UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe).
UiMenu now keys the face on the press, not on IsOpen.
4. Window-title/button text "vibrates" while dragging windows:
DrawStringDatPass snapped glyphs with MathF.Round — banker's
rounding. A centered label with a constant .5 fraction alternates
round-up/round-down across successive integers, double-stepping then
sticking while the background glides. Half-up Floor(v+0.5) snaps
every tie one way: uniform 1px steps in lock-step with sprites.
The fifth report (input row sticking out on window resize) did not
reproduce: a controller-bound fixture resize at 220/300/600px keeps the
whole input row inside the window (test added) — awaiting the owner's
exact gesture.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
479 lines
23 KiB
C#
479 lines
23 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>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 & 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 & 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);
|
|
}
|
|
}
|