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

File diff suppressed because one or more lines are too long

View file

@ -1047,7 +1047,7 @@ below and the new ledger row.
| 2 | Retail's SpewBox text carries a heavy black border around every glyph that acdream's does not. | **FIXED in the same round-4 items-1+2 follow-up commit as item 1.** Root cause: retail ships a SECOND ("background") glyph atlas per font, dilated 2px on every side, plus two border-pixel scalars (`NumHorizontalBorderPixels`/`NumVerticalBorderPixels`) that acdream's font reader dropped entirely (zero repo hits for `BorderPixel` before this fix) — so even the pre-existing `outline` parameter drew almost nothing once enabled. Both the missing border-pixel read AND the un-inflated background blit rect are fixed together (either alone is a no-op), `UiRenderContext.DrawStringDat` now runs retail's exact two-pass whole-string outline-then-fill model, and property 0x21/0x22 (Outline/OutlineColor) import onto every DAT-authored text element — not just the SpewBox — so this class of bug cannot recur element-by-element. The SpewBox's own line template authors outline ON with no colour (ctor black default), matching the user's screenshot. | | 2 | Retail's SpewBox text carries a heavy black border around every glyph that acdream's does not. | **FIXED in the same round-4 items-1+2 follow-up commit as item 1.** Root cause: retail ships a SECOND ("background") glyph atlas per font, dilated 2px on every side, plus two border-pixel scalars (`NumHorizontalBorderPixels`/`NumVerticalBorderPixels`) that acdream's font reader dropped entirely (zero repo hits for `BorderPixel` before this fix) — so even the pre-existing `outline` parameter drew almost nothing once enabled. Both the missing border-pixel read AND the un-inflated background blit rect are fixed together (either alone is a no-op), `UiRenderContext.DrawStringDat` now runs retail's exact two-pass whole-string outline-then-fill model, and property 0x21/0x22 (Outline/OutlineColor) import onto every DAT-authored text element — not just the SpewBox — so this class of bug cannot recur element-by-element. The SpewBox's own line template authors outline ON with no colour (ctor black default), matching the user's screenshot. |
| 3 | User-visible meta-markers ("IMPLEMENTED", "acdream has not yet extracted…"-style notices) leaking into live `/help` output. | **FIXED this commit, closes ISSUES.md #364.** Every honesty marker is now gone from user-visible text: `AllegianceOverview`/`HouseOverview`'s `[IMPLEMENTED]` tags and trailing "Subcommands NOT marked…" sentences, and `Day`/`Log`/`Render`/`Motd`'s appended "NOT YET IMPLEMENTED in acdream" tails are all removed, with the underlying retail text corrected/completed against the pseudo-C's own pristine consolidated data dumps (`Log` and `Motd` had also been silently truncated; `Render` had been entirely acdream-authored and is replaced with the real retail usage string). The three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings — `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`'s three "vtable slot" operands, previously believed undecodable, are the same pooled/mislabeled-data artifact this campaign has hit before (AP-113's precedent); reading the function's own disassembly for the `push imm32` preceding each constructor call resolves all three directly. `messagetypes` is now a real ported construction (`LogTextTypeEnumMapper::IsLegalChannel`'s 14-id whitelist + `LogTextTypeToString`'s name table + the exact join/wrap format) instead of an acdream summary. Register row AP-184 RETIRED. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` (51 tests, all passing) for the full citation trail. | | 3 | User-visible meta-markers ("IMPLEMENTED", "acdream has not yet extracted…"-style notices) leaking into live `/help` output. | **FIXED this commit, closes ISSUES.md #364.** Every honesty marker is now gone from user-visible text: `AllegianceOverview`/`HouseOverview`'s `[IMPLEMENTED]` tags and trailing "Subcommands NOT marked…" sentences, and `Day`/`Log`/`Render`/`Motd`'s appended "NOT YET IMPLEMENTED in acdream" tails are all removed, with the underlying retail text corrected/completed against the pseudo-C's own pristine consolidated data dumps (`Log` and `Motd` had also been silently truncated; `Render` had been entirely acdream-authored and is replaced with the real retail usage string). The three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings — `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`'s three "vtable slot" operands, previously believed undecodable, are the same pooled/mislabeled-data artifact this campaign has hit before (AP-113's precedent); reading the function's own disassembly for the `push imm32` preceding each constructor call resolves all three directly. `messagetypes` is now a real ported construction (`LogTextTypeEnumMapper::IsLegalChannel`'s 14-id whitelist + `LogTextTypeToString`'s name table + the exact join/wrap format) instead of an acdream summary. Register row AP-184 RETIRED. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` (51 tests, all passing) for the full citation trail. |
| 4 | (Not detailed to this session — reported as passed.) | **User-passed**, no action needed this round. | | 4 | (Not detailed to this session — reported as passed.) | **User-passed**, no action needed this round. |
| 5 | The main window's 1/2/3/4 indicator buttons don't open/close the floating chat windows on click — the user's retail memory says clicking should toggle them. | **FIXED this commit.** CH6b's decomp grep (`gmMainChatUI::ListenToElementMessage @0x004CDA80` has no click case for these ids) was TRUE but incomplete — it never checked `UIElement_Button`'s own generic click handler. `UIElement_Button::HandleButtonClick @0x00471E50` reads an Enum property (`0x12`) off the button itself and, if present, routes through `ICIDM`'s action map to `UIElementManager::DoVisibilityToggleAction @0x0045B660` (the SAME function the `Alt+1..4` keybinds reach), which broadcasts element message `0x31` to every element registered as a listener for that action id (via property `0x24`, read once by `UIElement::Initialize`) — the receiving element's generic `UIElement::ListenToElementMessage` base-class handler then toggles its own visibility per its OWN property `0x58`. The committed fixture (`chat_2100006f.json`) confirms the button HALF is genuinely armed: all four indicators carry a real Enum-kind property `0x12` = `0x10000514`-`0x10000517`. But the floating-window fixture (`chat_floaty_2100005b.json`) authors NO matching property `0x24`/`0x58` anywhere, so nothing in the shipped DAT registers a floating chat window as that action's listener — the generic mechanism is real and armed on the button side but has no proven target in the data available to us. Per CLAUDE.md, the user's retail memory is the axiom regardless: `ChatWindowController.BindIndicatorClicks` (new) wires each indicator's click to the SAME `ToggleFloatingChatWindow(windowId)` chokepoint the keybinds use, explicitly as USER-DIRECTED retail behavior. `SetIndicatorOpen` stays the sole writer of the `Selected` mirror (`SuppressSelfToggle` stays `true`) so the visual stays consistent through the click round trip. Full reconciliation in `docs/research/2026-08-09-chat-retail-window-shell.md` §1.4 (the `@0x004CDA80` citation stays true as a statement about that one function; the CONCLUSION is corrected). New tests in `ChatLayoutConformanceTests.cs` exercise the click round trip and confirm keybind and click drive the same chokepoint. | | 5 | The main window's 1/2/3/4 indicator buttons don't open/close the floating chat windows on click — the user's retail memory says clicking should toggle them. | **FIXED this commit.** CH6b's decomp grep (`gmMainChatUI::ListenToElementMessage @0x004CDA80` has no click case for these ids) was TRUE but incomplete — it never checked `UIElement_Button`'s own generic click handler. `UIElement_Button::HandleButtonClick @0x00471E50` reads an Enum property (`0x12`) off the button itself and, if present, routes through `ICIDM`'s action map to `UIElementManager::DoVisibilityToggleAction @0x0045B660` (the SAME function the `Alt+1..4` keybinds reach), which broadcasts element message `0x31` to every element registered as a listener for that action id (via property `0x24`, read once by `UIElement::Initialize`) — the receiving element's generic `UIElement::ListenToElementMessage` base-class handler then toggles its own visibility per its OWN property `0x58`. The committed fixture (`chat_2100006f.json`) confirms the button HALF is genuinely armed: all four indicators carry a real Enum-kind property `0x12` = `0x10000114`-`0x10000117` (corrected 2026-08-10 from an earlier misread of `0x10000514`-`0x10000517`; see `docs/research/2026-08-09-chat-retail-window-shell.md` §1.4's own correction note). But the floating-window fixture (`chat_floaty_2100005b.json`) authors NO matching property `0x24`/`0x58` anywhere, so nothing in the shipped DAT registers a floating chat window as that action's listener — the generic mechanism is real and armed on the button side but has no proven target in the data available to us. Per CLAUDE.md, the user's retail memory is the axiom regardless: `ChatWindowController.BindIndicatorClicks` (new) wires each indicator's click to the SAME `ToggleFloatingChatWindow(windowId)` chokepoint the keybinds use, explicitly as USER-DIRECTED retail behavior. `SetIndicatorOpen` stays the sole writer of the `Selected` mirror (`SuppressSelfToggle` stays `true`) so the visual stays consistent through the click round trip. Full reconciliation in `docs/research/2026-08-09-chat-retail-window-shell.md` §1.4 (the `@0x004CDA80` citation stays true as a statement about that one function; the CONCLUSION is corrected). New tests in `ChatLayoutConformanceTests.cs` exercise the click round trip and confirm keybind and click drive the same chokepoint. |
| 6 | A settings-surface finding. | **Deferred to the settings track** — out of this commit's scope; no code change this round. | | 6 | A settings-surface finding. | **Deferred to the settings track** — out of this commit's scope; no code change this round. |
Suite: 12,579 passed / 4 skipped / 0 failed (Release, complete solution — Suite: 12,579 passed / 4 skipped / 0 failed (Release, complete solution —

View file

@ -248,7 +248,9 @@ fixtures:**
DO author an Enum-kind property `0x12` (confirmed by `Kind: 0` = Enum in the DO author an Enum-kind property `0x12` (confirmed by `Kind: 0` = Enum in the
fixture's own property dump, matching `LayoutImporter.ConvertProperty`'s fixture's own property dump, matching `LayoutImporter.ConvertProperty`'s
`EnumBaseProperty → UiPropertyKind.Enum` mapping exactly), with values `EnumBaseProperty → UiPropertyKind.Enum` mapping exactly), with values
`0x10000514`-`0x10000517` in element-id order. This is a real, present, `0x10000114`-`0x10000117` in element-id order (re-verified directly against
the committed fixture's own `UnsignedValue` fields — an earlier pass here
misread these as `0x10000514`-`0x10000517`). This is a real, present,
correctly-typed action id — the button-click half of the generic mechanism correctly-typed action id — the button-click half of the generic mechanism
is genuinely armed. is genuinely armed.
- `chat_floaty_2100005b.json` — the floating chat window's own fixture - `chat_floaty_2100005b.json` — the floating chat window's own fixture
@ -262,14 +264,19 @@ fixtures:**
`DoVisibilityToggleAction` would look up the button's action id, find zero `DoVisibilityToggleAction` would look up the button's action id, find zero
registered listeners, and silently return — the click would fire a real registered listeners, and silently return — the click would fire a real
message with no receiver. message with no receiver.
- The four action-id VALUES (`0x10000514`-`0x10000517`) are also not - The four action-id VALUES (`0x10000114`-`0x10000117`) are also not
chat-specific: the identical four numbers, in the identical order, are chat-specific: the first two, `0x10000114` and `0x10000115`, are
`gmFriendsUI::PostInit`'s own child-element ids for its Add/Remove/Tell `m_prevButton`/`m_nextButton` child-element ids for an UNRELATED
buttons and friends listbox (`UIElement::GetChildRecursive(this, pagination widget elsewhere in the decomp (`UIElement::GetChildRecursive(this,
0x10000514)` etc., confirmed by direct read). This is almost certainly a 0x10000115)` / `(this, 0x10000114)`,
`acclient_2013_pseudo_c.txt:194343-194344`, confirmed by direct read) — a
coincidence of Turbine's global per-dat-file asset-id allocator (ids are coincidence of Turbine's global per-dat-file asset-id allocator (ids are
assigned client-wide, not scoped per panel), not a cross-reference — assigned client-wide, not scoped per panel), not a cross-reference.
nothing in `gmFriendsUI` registers for input actions either. (**Correction:** an earlier pass here misread the fixture's Enum values as
`0x10000514`-`0x10000517` and built a since-retracted claim that they
matched `gmFriendsUI::PostInit`'s own Add/Remove/Tell child ids — that
match does not exist at the correct `0x1000011x` values and `gmFriendsUI`
is not part of this finding.)
**Conclusion: the generic UI action system is real, it exists, and the **Conclusion: the generic UI action system is real, it exists, and the
buttons genuinely arm their half of it — but the authored DATA available to buttons genuinely arm their half of it — but the authored DATA available to

View file

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

View file

@ -389,7 +389,11 @@ public static class DatWidgetFactory
ElementId = info.Id, ElementId = info.Id,
SpriteResolve = resolve, SpriteResolve = resolve,
DatFont = datFont, 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. // The two 3-slice containers are Type-3 children of the meter element.
// ReadOrder determines draw order: the back track has a LOWER ReadOrder // ReadOrder determines draw order: the back track has a LOWER ReadOrder
@ -574,11 +578,15 @@ public static class DatWidgetFactory
OneLine = oneLine, OneLine = oneLine,
Centered = info.HJustify == HJustify.Center, Centered = info.HJustify == HJustify.Center,
RightAligned = info.HJustify == HJustify.Right, 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)) if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters))
field.MaxCharacters = maxCharacters; field.MaxCharacters = maxCharacters;
if (info.FontColor.HasValue) if (info.FontColor.HasValue)
field.TextColor = info.FontColor.Value; field.TextColor = info.FontColor.Value;
if (info.OutlineColor.HasValue)
field.OutlineColor = info.OutlineColor.Value;
return field; return field;
} }
@ -683,7 +691,13 @@ public static class DatWidgetFactory
LabelFont = labelFont, LabelFont = labelFont,
LabelColor = labelInfo.FontColor ?? info.FontColor LabelColor = labelInfo.FontColor ?? info.FontColor
?? System.Numerics.Vector4.One, ?? 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) if (face is not null)
{ {
@ -724,7 +738,11 @@ public static class DatWidgetFactory
: elementFont, : elementFont,
LabelColor = info.FontColor ?? System.Numerics.Vector4.One, LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
LabelAlign = UiButton.LabelAlignment.Left, 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) if (indicator is not null)
{ {

View file

@ -561,14 +561,15 @@ public static class LayoutImporter
} }
// Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & // Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField &
// 0x10. Only update while still at the default (false); derived-wins handled in // 0x10. NOT read here — reading it per-state as each StateDesc is visited is an
// ElementReader.Merge — same "only update if still at default" pattern as HJustify. // any-state "first wins" scan that can pick up a NON-effective state's property
if (!info.Outline // (e.g. a Pressed-only override) ahead of the state retail would actually use.
&& sd.Properties.TryGetValue(0x21u, out var outlineRaw) // ElementReader.ApplyCanonicalLegacyProjection (called once per element, right
&& outlineRaw is BoolBaseProperty outlineBool) // after every state is read) is the single correct source: it resolves 0x21
{ // through TryGetEffectiveProperty's DirectState-then-effective-default-state rule,
info.Outline = outlineBool.Value; // 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 // OutlineColor (0x22): ColorBaseProperty. Retail m_curOutlineColor, ctor default
// RGBAColor_Black. Only read when not already set — same pattern as FontColor. // 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")) else if (info.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal"; ActiveState = "Normal";
// else ActiveState stays "" (DirectState) // 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> /// <summary>
@ -158,6 +164,16 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
/// <summary>Label color (default white).</summary> /// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One; 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> /// <summary>
/// Controls only this element's authored media. Descendants still draw. /// Controls only this element's authored media. Descendants still draw.
/// Layered retail composites use this to place a template's chrome behind a /// 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 tx = (Width - lf.MeasureWidth(label)) * 0.5f;
float ty = (Height - lf.LineHeight) * 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> /// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One; 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 /// <summary>Optional authored face rectangle. Full-button by default; retail
/// UIOption_Checkbox uses its 13x13 indicator child as the button face.</summary> /// UIOption_Checkbox uses its 13x13 indicator child as the button face.</summary>
public float FaceLeft { get; set; } public float FaceLeft { get; set; }
@ -327,7 +337,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
? LabelOffsetX ? LabelOffsetX
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default) : (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
float ty = (Height - lf.LineHeight) * 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);
} }
uint dragSprite = _itemDragAcceptance switch uint dragSprite = _itemDragAcceptance switch

View file

@ -21,6 +21,17 @@ public sealed class UiCatalogSlot : UiItemSlot
public uint BackgroundSprite { get; init; } public uint BackgroundSprite { get; init; }
public UiDatFont? LabelFont { get; init; } public UiDatFont? LabelFont { get; init; }
public Vector4 LabelColor { get; init; } = new(0.92f, 0.88f, 0.70f, 1f); 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 IconLeft { get; init; }
public float IconTop { get; init; } public float IconTop { get; init; }
public float IconWidth { get; init; } public float IconWidth { get; init; }
@ -158,7 +169,7 @@ public sealed class UiCatalogSlot : UiItemSlot
{ {
string text = FitText(Label, labelWidth, LabelFont.MeasureWidth); string text = FitText(Label, labelWidth, LabelFont.MeasureWidth);
float y = MathF.Max(0f, (Height - LabelFont.LineHeight) * 0.5f); 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 else
{ {

View file

@ -28,6 +28,17 @@ public sealed class UiField : UiElement
public UiDatFont? DatFont { get; set; } public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; }
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); 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); public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
/// <summary>Selected-span highlight (translucent blue, behind the text).</summary> /// <summary>Selected-span highlight (translucent blue, behind the text).</summary>
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); 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); string vis = _text.Substring(start, end - start);
float vx = Padding + alignX + (MeasureTo(start) - _scrollX); 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); else ctx.DrawString(vis, vx, ty, TextColor, Font);
} }
@ -515,7 +526,7 @@ public sealed class UiField : UiElement
} }
if (DatFont is { } dat) 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) else if (Font is { } bitmap)
ctx.DrawString(line.Text, Padding, y, TextColor, 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 UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { 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; } public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
// Button face sprites (dat menu element 0x10000014). // 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) 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); 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 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f);
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f); 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 /// <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); /// set, the label renders through the dat-font two-pass blit (outline + fill);
/// when null, the debug <see cref="UiRenderContext.DefaultFont"/> bitmap font /// 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 tw = datFont.MeasureWidth(label);
float tx = (Width - tw) * 0.5f; float tx = (Width - tw) * 0.5f;
float ty = (Height - datFont.LineHeight) * 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) 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 (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 // Baseline of this line in local space; retail draws glyphs whose
// descriptor OffsetY already places them relative to the line top, so we // 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. // 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. // line on one row and pixel-aligned.
float baseY = System.MathF.Round(originY); 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; float pen = originX;
for (int i = 0; i < text.Length; i++) 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 // Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
// true fractional advance). Vertical: integer baseline + integer per-glyph // 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 gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
float gy = baseY + g.VerticalOffsetBefore; float gy = baseY + g.VerticalOffsetBefore;
float gw = g.Width; float gw = g.Width;
@ -361,10 +383,37 @@ public sealed class UiRenderContext
float iy = gy - by; float iy = gy - by;
float iw = gw + 2f * bx; float iw = gw + 2f * bx;
float ih = gh + 2f * by; 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( 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); 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 else
{ {

View file

@ -545,6 +545,16 @@ public sealed class UiText : UiElement, IUiDatStateful
// Normalised selection span (start <= end), if any. // Normalised selection span (start <= end), if any.
bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd); 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++) for (int i = 0; i < lines.Count; i++)
{ {
float y = baseY + i * lh; float y = baseY + i * lh;
@ -574,15 +584,31 @@ public sealed class UiText : UiElement, IUiDatStateful
hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0)); hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0));
} }
// Highlight sits BEHIND the line's text → sprite bucket, submitted // 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); ctx.DrawFill(hx, y, hw, lh, SelectionColor);
} }
} }
if (datFont is not null) 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 else
{
ctx.DrawString(text, lineX, y, lines[i].Color, bitmapFont); 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, Padding,
VerticalJustify); VerticalJustify);
foreach (TextRun run in runs) if (datFont is not null)
{ {
if (run.Text.Length == 0) continue; // Same BLOCK-level outline-then-fill batching as the multi-line path above
if (datFont is not null) // (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); if (run.Text.Length == 0) continue;
x += datFont.MeasureWidth(run.Text); 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); ctx.DrawString(run.Text, x, y, run.Color, bitmapFont);
x += bitmapFont!.MeasureWidth(run.Text); x += bitmapFont!.MeasureWidth(run.Text);
} }