feat(chat): retail text style — two-plane glyph outlines, authored SpewBox/chat styles
Campaign CH round 4, user-gate items 1+2. Root cause: retail ships a second (background) glyph atlas per font, dilated 2px on every side, plus two border-pixel scalars (Font.NumHorizontalBorderPixels/ NumVerticalBorderPixels) that acdream's font reader never read — so even the pre-existing outline parameter drew almost nothing once enabled. Landed together (either half alone is a no-op or a regression): - UiDatFont carries BorderX/BorderY from the DAT font resource. - UiRenderContext.DrawStringDat inflates the background blit's source and destination rect by that margin and restructures into retail's exact two-pass whole-string outline-then-fill model (UIElement_Text::DrawSelf), plus the 8-neighbour +-1px fallback for fonts with no background atlas. Corrects the stale "property 0xd" comment to the real ids, 0x21 (Outline) / 0x22 (OutlineColor). - LayoutDesc property 0x21/0x22 import (ElementInfo.Outline/ OutlineColor, LayoutImporter.ReadState, ElementReader.Merge/ ApplyCanonicalLegacyProjection, DatWidgetFactory.BuildText) so every authored-outline element across the DAT set is correct at once. - SpewBox: RetailFontId corrected from a round-3 heuristic (0x40000025) to the actually-authored 0x40000001 (18px bold serif), Outline=true set on the controller's UiText. Fill colour stays the user-gate-round-1-pinned yellow — font atlases are alpha-only (PFID_A8), so there is no baked shading that could explain the screenshot's gold as anything other than the outline itself. - Chat transcript: default fill now seeds from its authored ARGB(255,204,204,204) instead of an unrelated color-table slot (ChatTranscriptRenderer.BuildLines takes the transcript's own DefaultColor as a parameter); the 34-entry LogTextType table is untouched, and every existing CH1 conformance test stays green unmodified. Regenerated the committed chat_2100006f.json fixture from the real installed DAT, confirming end to end (not by missing-field default) that the transcript carries no outline. Tests: font-reader border fields + inflation math pinned against the real DAT font, two-pass draw ordering/tint/inflation via a new TextRenderer.DebugSpriteSegmentVerts test seam, property 0x21/0x22 import at both the ElementReader.Merge and StateDesc-property layers, SpewBox font/outline, and the chat default-shade seed with the color table proven untouched. Full Release suite: 12,610 passed / 4 skipped / 0 failed (AcDream.slnx, complete solution). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5b54387b8e
commit
bcc34ee301
22 changed files with 1995 additions and 105 deletions
|
|
@ -202,28 +202,64 @@ public sealed class UiRenderContext
|
|||
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>
|
||||
/// 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). Mirrors retail's
|
||||
/// <c>SurfaceWindow::DrawCharacter</c> (acclient 0x00442bd0): for each glyph
|
||||
/// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline),
|
||||
/// then the FOREGROUND atlas sub-rect tinted <paramref name="color"/> (the
|
||||
/// fill). The pen advances by
|
||||
/// 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 - (BaselineOffset)</c> via the
|
||||
/// glyph's OffsetY into the atlas.
|
||||
/// and at <c>baseline + VerticalOffsetBefore</c> via the glyph's OffsetY into
|
||||
/// the atlas.
|
||||
///
|
||||
/// <para><paramref name="outline"/> gates the black outline pass. Retail decides
|
||||
/// this PER text element: <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
|
||||
/// runs the outline pass only when <c>m_bitField & 0x10</c> is set — i.e. the
|
||||
/// element called <c>SetOutline(true)</c> (LayoutDesc property 0xd). The DEFAULT
|
||||
/// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an
|
||||
/// always-on outline shows as a grey halo over the solid menu panel. Pass
|
||||
/// <c>outline:true</c> only for elements retail outlines.</para>
|
||||
/// <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)
|
||||
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;
|
||||
|
||||
|
|
@ -232,7 +268,6 @@ public sealed class UiRenderContext
|
|||
// anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore.
|
||||
float originX = _current.X + x;
|
||||
float originY = _current.Y + y;
|
||||
float pen = originX;
|
||||
|
||||
// Snap the LINE baseline to a whole pixel ONCE. Retail's
|
||||
// SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y
|
||||
|
|
@ -246,8 +281,27 @@ public sealed class UiRenderContext
|
|||
// line on one row and pixel-aligned.
|
||||
float baseY = System.MathF.Round(originY);
|
||||
|
||||
var outlineTint = new Vector4(0f, 0f, 0f, color.W);
|
||||
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++)
|
||||
{
|
||||
if (!font.TryGetGlyph(text[i], out var g))
|
||||
|
|
@ -255,7 +309,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 baseY note above).
|
||||
// offset — never an independent per-glyph round (see DrawStringDat's baseY note).
|
||||
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
|
||||
float gy = baseY + g.VerticalOffsetBefore;
|
||||
float gw = g.Width;
|
||||
|
|
@ -263,35 +317,76 @@ public sealed class UiRenderContext
|
|||
|
||||
if (gw > 0f && gh > 0f)
|
||||
{
|
||||
// Background (outline) atlas pass, tinted black — drawn behind. Gated by
|
||||
// `outline` (retail's per-element m_bitField & 0x10); off by default so UI
|
||||
// text is crisp fill-only and free of the grey halo over solid panels.
|
||||
// Both passes route through ApplyAlpha (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.
|
||||
if (outline && font.BackgroundTexture != 0)
|
||||
{
|
||||
var (bu0, bv0, bu1, bv1) = AtlasUv(
|
||||
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
||||
font.BackgroundWidth, font.BackgroundHeight);
|
||||
DrawSpriteAbsolute(
|
||||
font.BackgroundTexture, gx, gy, gw, gh,
|
||||
bu0, bv0, bu1, bv1, outlineTint, applyAlpha: true);
|
||||
}
|
||||
|
||||
// Foreground (fill) atlas pass, tinted with the requested color.
|
||||
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, color, applyAlpha: true);
|
||||
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;
|
||||
var (bu0, bv0, bu1, bv1) = AtlasUv(
|
||||
g.OffsetX - bx, g.OffsetY - by, (int)iw, (int)ih,
|
||||
font.BackgroundWidth, font.BackgroundHeight);
|
||||
DrawSpriteAbsolute(font.BackgroundTexture, ix, iy, iw, ih, 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue