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:
Erik 2026-08-10 19:28:34 +02:00
parent 5b54387b8e
commit bcc34ee301
22 changed files with 1995 additions and 105 deletions

View file

@ -107,29 +107,74 @@ namespace AcDream.App.UI;
/// </list>
/// Register row AP-178 updated to record both dispositions.
/// </remarks>
/// <remarks>
/// <b>Campaign CH user-gate round 4 (2026-08-10),
/// <c>docs/research/2026-08-10-retail-ui-text-style.md</c>:</b> the earlier
/// "absent from both dats" finding for the SpewBox's own line template
/// (element <c>0x1000004A</c>, base style <c>0x10000377</c> in layout
/// <c>0x2100003F</c>) was WRONG — it was missed because the element is a
/// ROOT of its layout (a children-only walk skips it) and its font/color
/// live in a BaseElement in a DIFFERENT LayoutDesc plus a NAMED state, not
/// its own DirectState. Extending the sweep to walk roots, BaseElement/
/// BaseLayoutId inheritance, and named-state properties found it, resolving
/// three of AP-178's four remaining open sub-claims:
/// <list type="bullet">
/// <item><b>Font:</b> <see cref="RetailFontId"/> is now
/// <b>0x40000001</b> (18px bold serif), not the round-3 smallest-font
/// heuristic <c>0x40000025</c>. Three independent cross-checks: (1) base
/// style <c>0x10000377</c> authors FontDID <c>[0x40000001]</c> directly;
/// (2) the line template's authored height is 18px, exactly
/// <c>0x40000001</c>'s <c>MaxCharHeight</c>; (3) 4 items × 18px = 72px,
/// the authored box height (<see cref="SpewBoxHeight"/>).</item>
/// <item><b>Outline:</b> the line template's state <c>0x10000002</c>
/// authors property <c>0x21</c> (Outline) = <c>true</c>, outline color
/// NOT authored → ctor default black
/// (<see cref="UiRenderContext.DefaultOutlineColor"/>). This is the
/// "heavy black border" the user's screenshot showed and the earlier
/// rounds never reproduced — <see cref="UiText.Outline"/> is now set on
/// construction.</item>
/// <item><b>Position/extent:</b> confirmed AUTHORED, not merely
/// user-matched by luck. <c>0x10000048</c> (the gmSpewBoxUI root) is a
/// ROOT element of its own layout, so its parent is the viewport:
/// <c>pos(0,0)</c>, edge codes <c>L3/R3</c> ("centered" per
/// <c>ElementReader.ToAnchors</c>'s doc comment) and <c>T1</c>
/// (top-anchored) resolve to a 450×72 block horizontally centered, flush
/// to the viewport top — exactly what <see cref="TopOffset"/>=0 and the
/// per-frame centered <c>Left</c> recompute already do. AP-178's position
/// sub-claim retires.</item>
/// </list>
/// Only fill COLOR (see <see cref="SpewBoxColor"/> — the atlas is PFID_A8,
/// alpha-only, so it cannot carry baked shading; the user-pinned yellow
/// stands pending an exact retail measurement) and the AP-177 line-lifetime
/// timeout remain open.
/// </remarks>
internal sealed class SpewBoxController : IDisposable
{
/// <summary>
/// Retail dat Font id this controller resolves for its text
/// (Campaign CH user-gate round 3 — see the class remarks). Not
/// retail's own measured SpewBox font (unmeasurable — no FontDid
/// property was found on the authored element); the smallest DAT font
/// confirmed in use by any currently-imported retail LayoutDesc,
/// chosen so the rendered text is visibly smaller than the prior debug
/// fallback, per the user's report.
/// Retail dat Font id this controller resolves for its text. Campaign CH
/// user-gate round 4 (2026-08-10): AUTHORED, not a heuristic — base
/// style <c>0x10000377</c> (layout <c>0x2100003F</c>), which the
/// SpewBox's own line template (<c>0x1000004A</c> in layout
/// <c>0x21000011</c>) inherits, authors FontDID <c>[0x40000001]</c>
/// directly. <c>0x40000001</c> is the classic AC bold-serif UI face at
/// 18px (<c>MaxCharHeight=18</c>, matching the line template's own
/// authored 18px height and the 4×18=72px authored box height). See the
/// class remarks for the three-way cross-check and
/// <c>docs/research/2026-08-10-retail-ui-text-style.md</c> §4.2.
/// </summary>
internal const uint RetailFontId = 0x40000025u;
internal const uint RetailFontId = 0x40000001u;
/// <summary>
/// Register row AP-178 (screen position): retail's authored ABSOLUTE
/// screen position is still unknown — the LayoutDesc dump (see class
/// remarks) recovered the element's position as <c>(0,0)</c> relative
/// to a PARENT this sweep could not identify. Campaign CH user-gate
/// round 3 (2026-08-10): the user reported the box was not flush to
/// the very top of the screen; mounted at <c>0</c> now, per explicit
/// user direction — still acdream's own placement choice pending the
/// true retail parent/offset, but now matching the user's live report
/// instead of an arbitrary 60px placeholder. (The SIBLING row AP-177 —
/// Register row AP-178 (screen position): CONFIRMED authored, not a
/// placeholder that happens to match. Campaign CH user-gate round 4
/// (2026-08-10): the SpewBox root (<c>0x10000048</c>) is a ROOT element
/// of its own layout (<c>0x21000011</c>), so its parent is the
/// viewport — <c>pos(0,0)</c> plus edge codes <c>L3/R3</c> (centered)
/// and <c>T1</c> (top-anchored) resolve to exactly this: a fixed-width
/// block horizontally centered and flush to the viewport top. The
/// round-3 "user-directed approximation pending the true retail
/// parent/offset" framing is retired — the parent (the viewport root)
/// and the offset (0) are now both resolved. (The SIBLING row AP-177 —
/// the invented line-lifetime timeout — lives in
/// <see cref="SpewBoxState.DefaultLifetime"/>'s own doc comment, not
/// here; this controller does not own that concern.)
@ -173,13 +218,22 @@ internal sealed class SpewBoxController : IDisposable
/// (<c>colorBrightRed</c>) is still explicitly NOT this — retail's own
/// <c>BuildChatColorLookupTable</c> writes to <c>ChatInterface::m_chatLog</c>,
/// a completely different element tree the SpewBox never touches
/// (research doc §3.2.3); the LayoutDesc dump (see class remarks) also
/// never surfaced a colour property for this element. The exact retail
/// value simply happens to coincide with the Tell colour, per the user's
/// live observation. POSITION and FONT were re-addressed at Campaign CH
/// user-gate round 3 (2026-08-10) — see the class remarks and the
/// <see cref="TopOffset"/>/<see cref="RetailFontId"/> comments; both
/// remain acdream-directed approximations, not resolved retail values.
/// (research doc §3.2.3). POSITION and FONT were resolved as AUTHORED at
/// Campaign CH user-gate round 4 (2026-08-10) — see the class remarks and
/// the <see cref="TopOffset"/>/<see cref="RetailFontId"/> comments. The
/// AUTHORED value for THIS element's state <c>0x10000002</c> is actually
/// pure red <c>ARGB(255,255,0,0)</c> — but the user's live retail
/// screenshot shows gold/amber, not red, so the user's own eye remains
/// the axiom here (per <c>feedback_retail_oracle_no_whack_a_mole</c>).
/// Round 4 checked whether font <c>0x40000001</c>'s FILL-plane atlas
/// could explain the gold as baked shading over the pinned yellow: it
/// cannot — every dat font atlas (this one included) is <c>PFID_A8</c>,
/// alpha-only coverage with no per-pixel colour channel, so there is no
/// baked tint to compose with. The gold reading is therefore the OUTLINE
/// itself (a black border around a bright yellow glyph reads warmer/
/// richer than the same colour drawn flat) rather than atlas shading.
/// The user-pinned yellow stands; only an exact cdb capture of live
/// retail's <c>m_curFontColor</c> would resolve the remaining gap.
/// </summary>
private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.247f, 1f);
@ -245,6 +299,12 @@ internal sealed class SpewBoxController : IDisposable
ClickThrough = true,
ZOrder = int.MaxValue,
DefaultColor = SpewBoxColor,
// Campaign CH user-gate round 4: the line template's authored state
// 0x10000002 sets property 0x21 (Outline) = true with no authored
// outline colour, i.e. the ctor black default
// (UiRenderContext.DefaultOutlineColor) — the heavy black border in
// the user's screenshot. See the class remarks.
Outline = true,
Visible = false,
};
_text.LinesProvider = () => _lines;