fix(ui): round-5 review polish — S1 block outline pass, S2 non-UiText outline paths, S3 citation fix

Collects the post-gate polish left uncommitted by the killed round-5 agent
(S1/S3 + review fixes N1/N3/N4) and completes the missing S2 half:

- S1: UiText multi-line transcript + colored-run label now submit EVERY
  line/run's outline pass before ANY fill pass, matching retail's
  UIElement_Text::DrawSelf @0x00467aa0 whole-block walk. DrawStringDatPass
  is exposed for block-level batching; single lines keep DrawStringDat.
- S2 (completed this commit): authored outline 0x21/0x22 now reaches every
  text-bearing widget — UiButton, UiDatElement, UiField, UiMeter, UiMenu,
  UiCatalogSlot — seeded from the element's effective-default state exactly
  like UiText (BuildButton lifts the label-bearing Text child's authored
  value first, same chain as the label color). Per-STATE outline switching
  (dialog/character/combat buttons author 0x21 in state 0x3 only) is NOT
  ported — filed as register row AP-192 in this commit.
- S3: ChatWindowController reconciliation comment corrects the misread
  indicator action ids 0x10000514-17 -> 0x10000114-17 and re-attributes the
  id-coincidence to the pagination widget's m_prevButton/m_nextButton, not
  gmFriendsUI; register + window-shell research doc corrected to match.
- N1: LayoutImporter's duplicate per-state any-state-first-wins 0x21 read is
  deleted — ElementReader.ApplyCanonicalLegacyProjection's DirectState-then-
  effective-default resolution is the single source (the duplicate would have
  lit state-0x3-only outlines permanently once S2 widened consumption).
- N3: the outline pass tints with the outline color's OWN alpha, not the
  fill's (retail tints m_curOutlineColor and m_curTextColor independently).
- N4: the outline-inflated glyph SOURCE rect is clamped to the atlas bounds
  with matching dest shrink, porting CreateCharRectPair @0x00441480's edge
  behavior — edge glyphs crop instead of sampling a neighbour's texels.

Full Release suite: 12,610 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 22:15:20 +02:00
parent ed0dbff90a
commit aa6635aebf
14 changed files with 258 additions and 66 deletions

View file

@ -557,8 +557,9 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// buttons genuinely author property <c>0x12</c> as an Enum (not merely
/// a stray/mistyped property): <c>chat_2100006f.json</c>'s four
/// indicator elements each carry it, with values
/// <c>0x10000514</c>-<c>0x10000517</c> in id order. But the OTHER half
/// of the wiring is where retail's own data falls short: the floating
/// <c>0x10000114</c>-<c>0x10000117</c> in id order (an earlier pass here
/// misread these as <c>0x10000514</c>-<c>0x10000517</c>). But the OTHER
/// half of the wiring is where retail's own data falls short: the floating
/// chat window fixture (<c>chat_floaty_2100005b.json</c>) authors NO
/// Enum-kind property <c>0x24</c> anywhere (its one hit on property
/// number 36 is Integer-kind, an unrelated attribute) and NO property
@ -567,14 +568,19 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// ids, and <c>RegisterElementForInputAction</c> has exactly one call
/// site in the whole binary (the property-driven one above; no class
/// anywhere calls it directly in code). <c>DoVisibilityToggleAction</c>
/// would find zero listeners and silently no-op. (The four action-id
/// VALUES themselves are not chat-specific either — the SAME four
/// numbers are <c>gmFriendsUI::PostInit</c>'s own Add/Remove/Tell
/// button and friends-listbox child ids, a coincidence of Turbine's
/// global asset-id allocator, not a cross-reference.) So even with the
/// generic mechanism confirmed real and armed on the button side, the
/// authored DATA available to us does not wire a target — which is
/// consistent with, not a refutation of, the original CH6b grep.
/// would find zero listeners and silently no-op. (Two of the four action-id
/// VALUES are not chat-specific either — <c>0x10000114</c>/<c>0x10000115</c>
/// are <c>m_prevButton</c>/<c>m_nextButton</c> child ids for an unrelated
/// pagination widget elsewhere in the decomp
/// (<c>acclient_2013_pseudo_c.txt:194343-194344</c>), a coincidence of
/// Turbine's global asset-id allocator, not a cross-reference — an
/// earlier pass here misattributed this coincidence to
/// <c>gmFriendsUI::PostInit</c>'s Add/Remove/Tell child ids, which match
/// only the wrong, misread values above and are not actually involved.)
/// So even with the generic mechanism confirmed real and armed on the
/// button side, the authored DATA available to us does not wire a
/// target — which is consistent with, not a refutation of, the original
/// CH6b grep.
/// </para>
///
/// <para>

View file

@ -389,7 +389,11 @@ public static class DatWidgetFactory
ElementId = info.Id,
SpriteResolve = resolve,
DatFont = datFont,
// Outline 0x21 from the meter element (round-5 review S2).
Outline = info.Outline,
};
if (info.OutlineColor.HasValue)
m.OutlineColor = info.OutlineColor.Value;
// The two 3-slice containers are Type-3 children of the meter element.
// ReadOrder determines draw order: the back track has a LOWER ReadOrder
@ -574,11 +578,15 @@ public static class DatWidgetFactory
OneLine = oneLine,
Centered = info.HJustify == HJustify.Center,
RightAligned = info.HJustify == HJustify.Right,
// Outline 0x21 from the field element (round-5 review S2).
Outline = info.Outline,
};
if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters))
field.MaxCharacters = maxCharacters;
if (info.FontColor.HasValue)
field.TextColor = info.FontColor.Value;
if (info.OutlineColor.HasValue)
field.OutlineColor = info.OutlineColor.Value;
return field;
}
@ -683,7 +691,13 @@ public static class DatWidgetFactory
LabelFont = labelFont,
LabelColor = labelInfo.FontColor ?? info.FontColor
?? System.Numerics.Vector4.One,
// Outline 0x21 / OutlineColor 0x22 follow the same lift chain as the label
// and its color: the label-bearing Text child's authored value first, the
// button's own second (round-5 review S2).
Outline = labelInfo.Outline || info.Outline,
};
if ((labelInfo.OutlineColor ?? info.OutlineColor) is { } buttonOutlineColor)
button.OutlineColor = buttonOutlineColor;
if (face is not null)
{
@ -724,7 +738,11 @@ public static class DatWidgetFactory
: elementFont,
LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
LabelAlign = UiButton.LabelAlignment.Left,
// Outline 0x21 from the checkbox element itself (round-5 review S2).
Outline = info.Outline,
};
if (info.OutlineColor.HasValue)
button.OutlineColor = info.OutlineColor.Value;
if (indicator is not null)
{

View file

@ -561,14 +561,15 @@ public static class LayoutImporter
}
// Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField &
// 0x10. Only update while still at the default (false); derived-wins handled in
// ElementReader.Merge — same "only update if still at default" pattern as HJustify.
if (!info.Outline
&& sd.Properties.TryGetValue(0x21u, out var outlineRaw)
&& outlineRaw is BoolBaseProperty outlineBool)
{
info.Outline = outlineBool.Value;
}
// 0x10. NOT read here — reading it per-state as each StateDesc is visited is an
// any-state "first wins" scan that can pick up a NON-effective state's property
// (e.g. a Pressed-only override) ahead of the state retail would actually use.
// ElementReader.ApplyCanonicalLegacyProjection (called once per element, right
// after every state is read) is the single correct source: it resolves 0x21
// through TryGetEffectiveProperty's DirectState-then-effective-default-state rule,
// exactly like FontDid/HJustify/VJustify/FontColor already do. Round-5 review N1:
// this duplicate early read was masked while Outline only reached UiText; S2's
// widening to six more text-bearing widgets un-masks a state mismatch here.
// OutlineColor (0x22): ColorBaseProperty. Retail m_curOutlineColor, ctor default
// RGBAColor_Black. Only read when not already set — same pattern as FontColor.

View file

@ -118,6 +118,12 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
else if (info.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
// Outline 0x21 / OutlineColor 0x22 from the effective-default state, mirroring
// DatWidgetFactory.BuildText's seed of UiText (round-5 review S2).
Outline = info.Outline;
if (info.OutlineColor.HasValue)
OutlineColor = info.OutlineColor.Value;
}
/// <summary>
@ -158,6 +164,16 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded in the ctor from the
/// element's effective-default state, same as <see cref="UiText.Outline"/>
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
public bool Outline { get; set; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
/// <summary>
/// Controls only this element's authored media. Descendants still draw.
/// Layered retail composites use this to place a template's chrome behind a
@ -220,7 +236,7 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
{
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
}
}
}

View file

@ -88,6 +88,16 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded by DatWidgetFactory
/// from the element's effective-default state, same as <see cref="UiText.Outline"/>
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
public bool Outline { get; set; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
/// <summary>Optional authored face rectangle. Full-button by default; retail
/// UIOption_Checkbox uses its 13x13 indicator child as the button face.</summary>
public float FaceLeft { get; set; }
@ -327,7 +337,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
? LabelOffsetX
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
}
uint dragSprite = _itemDragAcceptance switch

View file

@ -21,6 +21,17 @@ public sealed class UiCatalogSlot : UiItemSlot
public uint BackgroundSprite { get; init; }
public UiDatFont? LabelFont { get; init; }
public Vector4 LabelColor { get; init; } = new(0.92f, 0.88f, 0.70f, 1f);
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Catalog slots are
/// controller-built (no authored element), so this is settable-only, for
/// parity with the other text-bearing widgets (round-5 review S2 —
/// per-STATE switching is AP-192).</summary>
public bool Outline { get; init; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; init; } = UiRenderContext.DefaultOutlineColor;
public float IconLeft { get; init; }
public float IconTop { get; init; }
public float IconWidth { get; init; }
@ -158,7 +169,7 @@ public sealed class UiCatalogSlot : UiItemSlot
{
string text = FitText(Label, labelWidth, LabelFont.MeasureWidth);
float y = MathF.Max(0f, (Height - LabelFont.LineHeight) * 0.5f);
ctx.DrawStringDat(LabelFont, text, labelLeft, y, LabelColor);
ctx.DrawStringDat(LabelFont, text, labelLeft, y, LabelColor, Outline, OutlineColor);
}
else
{

View file

@ -28,6 +28,17 @@ public sealed class UiField : UiElement
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded by DatWidgetFactory
/// from the element's effective-default state, same as <see cref="UiText.Outline"/>
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
public bool Outline { get; set; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
/// <summary>Selected-span highlight (translucent blue, behind the text).</summary>
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
@ -434,7 +445,7 @@ public sealed class UiField : UiElement
{
string vis = _text.Substring(start, end - start);
float vx = Padding + alignX + (MeasureTo(start) - _scrollX);
if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor);
if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor, Outline, OutlineColor);
else ctx.DrawString(vis, vx, ty, TextColor, Font);
}
@ -515,7 +526,7 @@ public sealed class UiField : UiElement
}
if (DatFont is { } dat)
ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor);
ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor, Outline, OutlineColor);
else if (Font is { } bitmap)
ctx.DrawString(line.Text, Padding, y, TextColor, bitmap);
}

View file

@ -155,6 +155,17 @@ public sealed class UiMenu : UiElement
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). No authored menu element
/// carries it today; settable for parity with the other text-bearing widgets
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
public bool Outline { get; set; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
// Button face sprites (dat menu element 0x10000014).
@ -458,7 +469,7 @@ public sealed class UiMenu : UiElement
private void DrawLabel(UiRenderContext ctx, string s, float x, float y, Vector4 color)
{
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, color);
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, color, Outline, OutlineColor);
else ctx.DrawString(s, x, y, color, Font);
}

View file

@ -29,6 +29,16 @@ public sealed class UiMeter : UiElement, IUiDatStateful
public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f);
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded by DatWidgetFactory
/// from the element's effective-default state, same as <see cref="UiText.Outline"/>
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
public bool Outline { get; set; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
/// <summary>Retail dat font (Font 0x40000000) for the "cur/max" overlay. When
/// set, the label renders through the dat-font two-pass blit (outline + fill);
/// when null, the debug <see cref="UiRenderContext.DefaultFont"/> bitmap font
@ -168,7 +178,7 @@ public sealed class UiMeter : UiElement, IUiDatStateful
float tw = datFont.MeasureWidth(label);
float tx = (Width - tw) * 0.5f;
float ty = (Height - datFont.LineHeight) * 0.5f;
ctx.DrawStringDat(datFont, label, tx, ty, LabelColor);
ctx.DrawStringDat(datFont, label, tx, ty, LabelColor, Outline, OutlineColor);
}
else if (ctx.DefaultFont is { } font)
{

View file

@ -263,6 +263,48 @@ public sealed class UiRenderContext
{
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.
@ -281,26 +323,6 @@ public sealed class UiRenderContext
// line on one row and pixel-aligned.
float baseY = System.MathF.Round(originY);
if (outline)
{
Vector4 oc = outlineColor ?? DefaultOutlineColor;
var outlineTint = new Vector4(oc.X, oc.Y, oc.Z, color.W);
// PASS 0 — outline, whole string (acclient 0x00467aa0, var_b0 starts at 0).
DrawStringDatPass(font, text, originX, baseY, outlineTint, isOutlinePass: true);
}
// PASS 1 (or the only pass, when outline is off) — fill, whole string.
DrawStringDatPass(font, text, originX, baseY, color, isOutlinePass: false);
}
/// <summary>One whole-string pass of <see cref="DrawStringDat"/>'s two-pass
/// 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"/>, so both passes advance
/// identically and stay in lock-step without sharing mutable state.</summary>
private void DrawStringDatPass(
UiDatFont font, string text, float originX, float baseY, Vector4 tint, bool isOutlinePass)
{
float pen = originX;
for (int i = 0; i < text.Length; i++)
{
@ -309,7 +331,7 @@ public sealed class UiRenderContext
// 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 DrawStringDat's baseY note).
// 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;
@ -361,10 +383,37 @@ public sealed class UiRenderContext
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(
g.OffsetX - bx, g.OffsetY - by, (int)iw, (int)ih,
(int)srcX, (int)srcY, (int)srcW, (int)srcH,
font.BackgroundWidth, font.BackgroundHeight);
DrawSpriteAbsolute(font.BackgroundTexture, ix, iy, iw, ih, bu0, bv0, bu1, bv1, tint, applyAlpha: true);
DrawSpriteAbsolute(font.BackgroundTexture, destX, destY, destW, destH, bu0, bv0, bu1, bv1, tint, applyAlpha: true);
}
else
{

View file

@ -545,6 +545,16 @@ public sealed class UiText : UiElement, IUiDatStateful
// Normalised selection span (start <= end), if any.
bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd);
// Gather each visible line's draw geometry first, rather than drawing text
// inline in this loop. Retail's UIElement_Text::DrawSelf @0x00467aa0 walks
// EVERY glyph of the whole BLOCK in the outline pass before any fill; calling
// DrawStringDat once per line here (outline+fill, outline+fill, ...) would let
// line N+1's outline draw AFTER line N's fill and notch a descender that pokes
// up into the line above (round-5 review S1). The DAT-font branch below instead
// submits every visible line's outline pass, THEN every visible line's fill
// pass. The bitmap-font branch has no outline concept and draws inline as before.
List<(string Text, float X, float Y, Vector4 Color)>? datLines = null;
for (int i = 0; i < lines.Count; i++)
{
float y = baseY + i * lh;
@ -574,15 +584,31 @@ public sealed class UiText : UiElement, IUiDatStateful
hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0));
}
// Highlight sits BEHIND the line's text → sprite bucket, submitted
// before this line's DrawStringDat.
// before this line's text (still true: this happens before either
// pass below runs for ANY line).
ctx.DrawFill(hx, y, hw, lh, SelectionColor);
}
}
if (datFont is not null)
ctx.DrawStringDat(datFont, text, lineX, y, lines[i].Color, Outline, OutlineColor);
{
(datLines ??= new()).Add((text, lineX, y, lines[i].Color));
}
else
{
ctx.DrawString(text, lineX, y, lines[i].Color, bitmapFont);
}
}
if (datLines is not null)
{
// Outline-OFF stays a single fill-only pass per line — byte-identical to
// the pre-S1 per-line DrawStringDat(outline:false) submission order.
if (Outline)
foreach (var line in datLines)
ctx.DrawStringDatPass(datFont!, line.Text, line.X, line.Y, OutlineColor, isOutlinePass: true);
foreach (var line in datLines)
ctx.DrawStringDatPass(datFont!, line.Text, line.X, line.Y, line.Color, isOutlinePass: false);
}
}
@ -618,16 +644,31 @@ public sealed class UiText : UiElement, IUiDatStateful
Padding,
VerticalJustify);
foreach (TextRun run in runs)
if (datFont is not null)
{
if (run.Text.Length == 0) continue;
if (datFont is not null)
// Same BLOCK-level outline-then-fill batching as the multi-line path above
// (round-5 review S1) — several colored runs on ONE line share one Outline
// flag, and retail's outline pass covers the whole block before any fill.
var runGeometry = new List<(string Text, float X, Vector4 Color)>();
float penX = x;
foreach (TextRun run in runs)
{
ctx.DrawStringDat(datFont, run.Text, x, y, run.Color, Outline, OutlineColor);
x += datFont.MeasureWidth(run.Text);
if (run.Text.Length == 0) continue;
runGeometry.Add((run.Text, penX, run.Color));
penX += datFont.MeasureWidth(run.Text);
}
else
if (Outline)
foreach (var run in runGeometry)
ctx.DrawStringDatPass(datFont, run.Text, run.X, y, OutlineColor, isOutlinePass: true);
foreach (var run in runGeometry)
ctx.DrawStringDatPass(datFont, run.Text, run.X, y, run.Color, isOutlinePass: false);
}
else
{
foreach (TextRun run in runs)
{
if (run.Text.Length == 0) continue;
ctx.DrawString(run.Text, x, y, run.Color, bitmapFont);
x += bitmapFont!.MeasureWidth(run.Text);
}