diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 308c03bb..052cca56 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -139,6 +139,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` |
| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
+| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
---
diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index 64c0fc6b..a6281eaa 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -74,12 +74,52 @@ public sealed class ChatWindowController
public UiScrollbar Scrollbar { get; private set; } = null!;
/// Channel-selector menu widget.
- public UiChannelMenu Menu { get; private set; } = null!;
+ public UiMenu Menu { get; private set; } = null!;
// ── Private state ──────────────────────────────────────────────────────
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
+ // ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
+
+ private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
+ {
+ ("Squelch (ignore)", null),
+ ("Tell to Selected", null),
+ ("Chat to All", ChatChannelKind.Say),
+ ("Tell to Fellows", ChatChannelKind.Fellowship),
+ ("Tell to General Chat", ChatChannelKind.General),
+ ("Tell to LFG Chat", ChatChannelKind.Lfg),
+ ("Tell to Society Chat", ChatChannelKind.Society),
+ ("Tell to Monarch", ChatChannelKind.Monarch),
+ ("Tell to Patron", ChatChannelKind.Patron),
+ ("Tell to Vassals", ChatChannelKind.Vassals),
+ ("Tell to Allegiance", ChatChannelKind.Allegiance),
+ ("Tell to Trade Chat", ChatChannelKind.Trade),
+ ("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
+ ("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
+ };
+
+ private static string ChannelButtonLabel(ChatChannelKind k) => k switch
+ {
+ ChatChannelKind.Say => "Chat",
+ ChatChannelKind.General => "General",
+ ChatChannelKind.Trade => "Trade",
+ ChatChannelKind.Lfg => "LFG",
+ ChatChannelKind.Fellowship => "Fellow",
+ ChatChannelKind.Allegiance => "Alleg",
+ ChatChannelKind.Patron => "Patron",
+ ChatChannelKind.Vassals => "Vassals",
+ ChatChannelKind.Monarch => "Monarch",
+ ChatChannelKind.Roleplay => "Roleplay",
+ ChatChannelKind.Society => "Society",
+ ChatChannelKind.Olthoi => "Olthoi",
+ _ => "Chat",
+ };
+
+ private static bool ChannelAvailable(ChatChannelKind k)
+ => k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
+
/// Window height before maximize (stored to restore on un-maximize).
private float _normalHeight;
/// Window top before maximize.
@@ -110,7 +150,7 @@ public sealed class ChatWindowController
/// Fallback debug bitmap font (used when
/// is null).
/// Dat RenderSurface id → (GL tex handle, px width, px height).
- /// Forwarded to and .
+ /// Forwarded to and .
public static ChatWindowController? Bind(
ElementInfo rootInfo,
ImportedLayout layout,
@@ -216,29 +256,23 @@ public sealed class ChatWindowController
c.Scrollbar = bar;
}
- // ── Channel menu — replace the imported menu placeholder ──────────
- var menuEl = layout.FindElement(MenuId);
- if (menuEl?.Parent is { } menuParent)
+ // ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
+ if (layout.FindElement(MenuId) is UiMenu menu)
{
- c.Menu = new UiChannelMenu
+ menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
+ menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
+ menu.PopupBgSprite = MenuPopupBg;
+ menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
+ menu.Items = System.Array.ConvertAll(ChannelItems,
+ t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
+ menu.Selected = (object?)c._activeChannel;
+ menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
+ menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
+ menu.OnSelect = p =>
{
- Left = menuEl.Left,
- Top = menuEl.Top,
- Width = menuEl.Width,
- Height = menuEl.Height,
- Anchors = menuEl.Anchors,
- DatFont = datFont,
- Font = debugFont,
- SpriteResolve = resolve,
- NormalSprite = MenuNormal,
- PressedSprite = MenuPressed,
- PopupBgSprite = MenuPopupBg,
- ItemNormalSprite = MenuItemRow,
- ItemHighlightSprite = MenuItemSelected,
+ if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
};
- c.Menu.OnChannelChanged = k => c._activeChannel = k;
- menuParent.RemoveChild(menuEl);
- menuParent.AddChild(c.Menu);
+ c.Menu = menu;
}
// ── Send button — Enter-alternate submit trigger ──────────────────
@@ -269,8 +303,8 @@ public sealed class ChatWindowController
c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left);
c.Input.ResetAnchorCapture();
}
- var onChanged = c.Menu.OnChannelChanged;
- c.Menu.OnChannelChanged = k => { onChanged?.Invoke(k); ReflowInputRow(); };
+ var onSelect = c.Menu.OnSelect;
+ c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
ReflowInputRow();
}
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 20b688a1..556fc3ee 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -59,6 +59,7 @@ public static class DatWidgetFactory
UiElement e = info.Type switch
{
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
+ 6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
_ => new UiDatElement(info, resolve), // generic fallback for all other types
diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiMenu.cs
similarity index 56%
rename from src/AcDream.App/UI/UiChannelMenu.cs
rename to src/AcDream.App/UI/UiMenu.cs
index a64e1aa4..b3e1595d 100644
--- a/src/AcDream.App/UI/UiChannelMenu.cs
+++ b/src/AcDream.App/UI/UiMenu.cs
@@ -1,48 +1,43 @@
using System;
+using System.Collections.Generic;
using System.Numerics;
-using AcDream.UI.Abstractions;
namespace AcDream.App.UI;
///
-/// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail
-/// gmMainChatUI::InitTalkFocusMenu @0x4cdc50 + UIElement_Menu::MakePopup
-/// @0x46d310: the button is labelled with the active target; clicking opens a
-/// TWO-COLUMN popup of 14 talk-focus items on the dat-driven menu chrome (panel +
-/// per-row + selected-row sprites, 191×17 rows, from LayoutDesc 0x21000006 elements
-/// 0x1000001C/1D/1E). Items are code-populated exactly as retail populates them.
-/// Unavailable channels render greyed (retail ResetAllTalkFocusMenuButtons →
-/// SetState(disabled), colorPink).
+/// Generic dropdown menu. Ports retail UIElement_Menu
+/// (RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163) +
+/// UIElement_Menu::MakePopup @0x46d310: the button is labelled with
+/// the active target; clicking opens a column-major popup on the dat-driven menu
+/// chrome (panel + per-row + selected-row sprites). Items and all chat-channel
+/// knowledge are populated by the controller, not baked into this widget. Built
+/// by for Type-6 elements.
///
-public sealed class UiChannelMenu : UiElement
+public sealed class UiMenu : UiElement
{
- /// One menu row: its label + the channel it selects (null = special/no-op
- /// item such as Squelch or Tell-to-Selected, deferred).
- public readonly record struct Item(string Label, ChatChannelKind? Channel);
+ /// One menu row: its label + an opaque payload the controller maps back.
+ public readonly record struct MenuItem(string Label, object? Payload);
- /// The 14 retail talk-focus items in retail order — left column rows 0–6,
- /// right column rows 7–13 (matching the live retail menu).
- public static readonly Item[] Items =
- {
- new("Squelch (ignore)", null), // 0 special (squelch — deferred)
- new("Tell to Selected", null), // 1 special (selected target — deferred)
- new("Chat to All", ChatChannelKind.Say), // 2
- new("Tell to Fellows", ChatChannelKind.Fellowship), // 3
- new("Tell to General Chat", ChatChannelKind.General), // 4
- new("Tell to LFG Chat", ChatChannelKind.Lfg), // 5
- new("Tell to Society Chat", ChatChannelKind.Society), // 6
- new("Tell to Monarch", ChatChannelKind.Monarch), // 7
- new("Tell to Patron", ChatChannelKind.Patron), // 8
- new("Tell to Vassals", ChatChannelKind.Vassals), // 9
- new("Tell to Allegiance", ChatChannelKind.Allegiance), // 10
- new("Tell to Trade Chat", ChatChannelKind.Trade), // 11
- new("Tell to Roleplay Chat", ChatChannelKind.Roleplay), // 12
- new("Tell to Olthoi Chat", ChatChannelKind.Olthoi), // 13
- };
+ /// The rows, populated by the controller. Laid out column-major:
+ /// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.
+ public IReadOnlyList