User directive (gate 2, verbatim): "mark all options that are not implemented now, so I can clearly see what is not implemented." Store-only rows keep full interactivity (still persist/send) but render their caption in a shared dimmed grey (UiRenderContext.StoreOnlyCaptionColor, matching the existing UiMenu.TextColorGhosted convention) instead of white/DAT color. No invented marker text anywhere -- the dim IS the marker. Config tab (ConfigOptionsPageController, 21 of 27 rows dimmed): Sound Features menu, Interface Sound trio, Play Sound Only When Active (AP-199); Screen Brightness, Automatic Degrades, Graphics Performance, Degrade Distance, the four Rendering Quality menus, Building Detail Textures, Multi-Pass Alpha (AP-198); Camera Stiffness, Camera Adjustment Speed, Align To Slope, Mouse Look Sensitivity, Invert Mouselook Y Axis, Use Mouse Turning (TS-74); Chat Font Face/Size (AP-200). NOT dimmed: Sound/Ambient trios, Resolution, Full Screen (LIVE), VSync and Field of View (NEXT-LAUNCH -- still implemented, just deferred to next process start, per the controller's own doc). Character tab (CharacterOptionsPageController, 35 of 50 rows dimmed): every Group A (wire+store only) and Group D (deferred) row, plus the Group B rows the OP4 gate script's own step 16 confirms are unbound (ShowTooltips, SideBySideVitals, SpellDuration, AdvancedCombatUI, StayInChatMode, DisableMostWeatherEffects, PersistentAtDay, FilterLanguage, MainPackPreferred). NOT dimmed (15 rows): the six ListenTo*Chat ids (TurbineChatMembershipGate), DisableDistanceFog/ DisplayTimeStamps/ToggleRun (bound at GameWindow.cs), the Group-C re-point (ViewCombatTarget/VividTargetingIndicator/CoordinatesOnRadar/ AutoTarget/AutoRepeatAttack), and DragItemOnPlayerOpensSecureTrade (TS-48). Cross-checked against actual shipped consumers via source grep, not just the research doc's Group table, since OP4 only wired a subset of the doc's aspirational Group B. Configure Keyboard (KeyboardConfigController): a row whose RetailActionIdentityTable lookup fails (MappedAction null -- AP-203's Emote/CharacterSettings set) dims its synthesized caption; the key buttons stay fully bindable/persisted/conflict-checked. Chat tab (ChatOptionsPageController): audited, zero store-only rows -- every filter block and both opacity sliders already have a live consumer (ChatWindowState / RetailWindowOpacityController). Ambiguity flagged, not guessed: the character-options-map.md research doc lists AcceptLootPermits in BOTH Group A and Group C; its only code site (LiveSessionRuntimeFactory.cs, the /consent command) is a second setter for the same server bit, not a behavioral reader, so it is classified Group A / dimmed here. Register: AD-78 documents the convention (retail dims nothing; this is a deliberate acdream-only divergence that retires as consumers land). New per-surface conformance tests pin the exact dimmed/live set against a literal expected list, so wiring a future consumer without also flipping its row's literal fails the build: CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly + Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows, ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly, KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite. Build green; full Release suite 13,086 passed / 4 skipped / 0 failed (baseline 13,082/4/0 -- delta is exactly the four new tests above). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
469 lines
22 KiB
C#
469 lines
22 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.
|
|
float baseY = System.MathF.Round(originY);
|
|
|
|
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).
|
|
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
|
|
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);
|
|
}
|
|
}
|