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); } /// /// Per-frame drawing context passed through the /// tree. Wraps a (our 2D sprite batcher) and a /// transform stack so elements can draw in local coordinates. /// /// Retail equivalent: the implicit context FUN_005da8f0 walks with /// when iterating the UI tree. Our version is explicit so it plugs /// cleanly into Silk.NET. /// 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 _stack = new(); private Vector2 _current; private readonly System.Collections.Generic.List _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 _alphaStack = new(); private float _alpha = 1f; /// Current cumulative opacity multiplier applied to sprite + rect draws. public float AlphaMod => _alpha; /// Multiply into the running opacity. Pair with . public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; } /// Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays /// that must stay opaque even inside a translucent window. Pair with . 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; } /// Push a relative translate. Must be paired with . 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; /// Intersect descendant drawing with a local-space viewport. 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); } /// 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 . 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)); } /// Solid-colour fill drawn in the SPRITE bucket (painter order with text), for /// a panel BACKGROUND that text draws on top of. composites after /// all sprites and would cover the text — use this for backgrounds, that for foreground /// fills (carets, vital bars). 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); } /// Multiply the current window opacity into a draw color's alpha. 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); } /// Retail UIElement_Text's constructor outline color default /// (RGBAColor_Black, UIElement_Text::UIElement_Text @0x004686cb). /// Used by when the caller doesn't supply an /// explicit outlineColor (LayoutDesc property 0x22 is authored on only /// 9 elements in the whole DAT set — every other outlined element uses this). public static readonly Vector4 DefaultOutlineColor = new(0f, 0f, 0f, 1f); /// /// Draw a single line of text with a retail dat font (), /// at , = the top-left of the /// typographic block (in this element's local space). The pen advances by /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter and each /// glyph is positioned at pen + HorizontalOffsetBefore on the X axis /// and at baseline + VerticalOffsetBefore via the glyph's OffsetY into /// the atlas. /// /// /// Two-pass outline modelUIElement_Text::DrawSelf /// (acclient 0x00467aa0): var_b0 = (m_bitField & 0x10) ? 0 : 1; — when /// the outline bit is CLEAR the loop runs once (fill only, var_b0==1); /// 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). /// /// /// /// gates the outline passes. Retail decides this PER /// text element: the bit is m_bitField & 0x10, set by LayoutDesc /// property 0x21 (SetOutline @0x0046a81c) — NOT property 0xd (an /// earlier comment here named the wrong id: 0xd is the *switch-case index* inside /// UIElement_Text::OnSetAttribute, not the authored property). The DEFAULT /// is OFF (one fill-only pass, ctor m_bitField=0x300 clears bit 0x10): /// outlining is opt-in per element (~100 authored rows across 15 layouts). /// is the element's m_curOutlineColor /// (LayoutDesc property 0x22, ctor default — /// black; only 9 elements in the whole DAT set author a non-black value). /// /// /// /// Outline mechanism — retail picks by data, per font /// (feedback_retail_dispatch_is_data_driven shape): when /// is true, the outline pass blits the /// BACKGROUND (dilated) atlas sub-rect, inflated by /// / on every /// side of both the source AND destination rect /// (SurfaceWindow::DrawCharacter @0x00442d3a + /// CreateCharRectPair @0x00441480) — 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 /// (UIElement_Text::DrawSelf 0x00467d7e-0x00467e14). /// /// 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); } /// /// One whole-string pass of 's two-pass outline+fill /// model: either every glyph's outline ( true) or /// every glyph's fill. Recomputes the pen from scratch — a pure function of /// /// /// , so repeated calls advance identically and stay in /// lock-step without sharing mutable state. /// /// /// Exposed for BLOCK-level batching (round-5 review S1): a caller that draws /// several lines or runs sharing ONE Outline flag ('s /// multi-line transcript and colored-run label) must submit EVERY line's outline /// pass before ANY line's fill pass — retail's UIElement_Text::DrawSelf /// (0x00467aa0) walks every glyph of the whole block in the outline pass before any /// fill. Calling 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 =true for every /// line, THEN once per line with =false for every /// line. A single independent line can still call directly. /// /// 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); } } /// Foreground (fill) atlas blit, tinted with the requested text color. /// Both passes route through DrawSpriteAbsolute(..., applyAlpha: true) so a /// window's opacity fades glyphs exactly like its chrome/background sprites — /// retail's ChatInterface::SetOpacity (0x004F3120) fades the whole /// composited surface. 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); } /// 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. 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); } } } } /// Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to /// normalized UVs for an atlas of x /// . Guards against a zero-sized atlas. 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); } }