diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index 6e8aafa5..e02efb56 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -50,8 +50,11 @@ public sealed class ChatWindowController
private const uint DownSprite = 0x06004C6Cu;
// Channel menu sprite ids (confirmed in chat element dump).
- private const uint MenuNormal = 0x06004D65u;
- private const uint MenuPressed = 0x06004D66u;
+ private const uint MenuNormal = 0x06004D65u; // button face
+ private const uint MenuPressed = 0x06004D66u; // button pressed
+ private const uint MenuPopupBg = 0x0600124Cu; // popup panel fill (element 0x1000001C)
+ private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E)
+ private const uint MenuItemSelected = 0x0600124Du; // active channel row
// ── Public surface ─────────────────────────────────────────────────────
@@ -225,11 +228,14 @@ public sealed class ChatWindowController
Width = menuEl.Width,
Height = menuEl.Height,
Anchors = menuEl.Anchors,
- DatFont = datFont,
- Font = debugFont,
- SpriteResolve = resolve,
- NormalSprite = MenuNormal,
- PressedSprite = MenuPressed,
+ DatFont = datFont,
+ Font = debugFont,
+ SpriteResolve = resolve,
+ NormalSprite = MenuNormal,
+ PressedSprite = MenuPressed,
+ PopupBgSprite = MenuPopupBg,
+ ItemNormalSprite = MenuItemRow,
+ ItemHighlightSprite = MenuItemSelected,
};
c.Menu.OnChannelChanged = k => c._activeChannel = k;
menuParent.RemoveChild(menuEl);
diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs
index 0403527c..01d1f735 100644
--- a/src/AcDream.App/UI/UiChannelMenu.cs
+++ b/src/AcDream.App/UI/UiChannelMenu.cs
@@ -6,11 +6,13 @@ namespace AcDream.App.UI;
///
/// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail
-/// gmMainChatUI::InitTalkFocusMenu @0x4cdc50: the button is labelled "Chat";
-/// clicking opens a TWO-COLUMN popup of 14 talk-focus items (Squelch, Tell to Selected,
-/// Chat to All, Tell to Fellows, …). Selecting a channel item sets the active outbound
-/// channel (retail SetTalkFocus; here ). The items
-/// are code-populated exactly as retail populates them, not a dat-layout port.
+/// 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).
///
public sealed class UiChannelMenu : UiElement
{
@@ -39,21 +41,34 @@ public sealed class UiChannelMenu : UiElement
};
private const int Rows = 7; // items per column
- private const float ItemH = 16f; // row height
- private const float ColW = 150f; // column width (fits "Tell to Roleplay Chat")
+ private const float ItemH = 17f; // row height (dat item template 0x1000001E H=17)
+ private const float ColW = 191f; // column width (dat item template W=191)
/// The channel the player's typed text currently goes to.
public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say;
public Action? OnChannelChanged { get; set; }
+ /// Per-channel availability gate (retail greys channels you are not in).
+ /// Defaults to a static approximation; the controller can inject live channel state.
+ public Func? AvailabilityProvider { get; set; }
+
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
public Func? SpriteResolve { get; set; }
+
+ // Button face sprites (dat menu element 0x10000014).
public uint NormalSprite { get; set; }
public uint PressedSprite { get; set; }
+ // Popup chrome sprites (dat menu popup template, layout 0x21000006).
+ public uint PopupBgSprite { get; set; } // 0x0600124C — panel fill (191×2 tiles)
+ public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17)
+ public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row
+
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
- /// Popup panel fill — the retail talk-focus menu is a warm tan/orange.
- public Vector4 PopupColor { get; set; } = new(0.56f, 0.40f, 0.18f, 1f);
+ /// Available item text (retail white #FFFFFF).
+ public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f);
+ /// Greyed/unavailable item text (retail colorPink salmon, acclient 0x81c528).
+ public Vector4 TextColorGhosted { get; set; } = new(1f, 0.588f, 0.588f, 1f);
private bool _open;
private static float PopupW => 2 * ColW;
@@ -61,8 +76,17 @@ public sealed class UiChannelMenu : UiElement
public UiChannelMenu() { CapturesPointerDrag = true; }
+ /// True if the channel is currently joinable/visible. Defaults to a static
+ /// approximation matching the common case (Say/General/Trade/LFG); the fellowship +
+ /// allegiance-hierarchy channels need membership state acdream does not yet track
+ /// (deferred → greyed). The controller can override via .
+ private bool IsAvailable(ChatChannelKind ch)
+ => AvailabilityProvider?.Invoke(ch)
+ ?? ch is ChatChannelKind.Say or ChatChannelKind.General
+ or ChatChannelKind.Trade or ChatChannelKind.Lfg;
+
/// The button face label = the active talk target (retail updates the
- /// "Chat" button to whichever target you pick). "Chat" = Chat-to-All (Say).
+ /// button to whichever target you pick). "Chat" = Chat-to-All (Say).
private string ButtonText => Selected switch
{
ChatChannelKind.Say => "Chat",
@@ -82,27 +106,40 @@ public sealed class UiChannelMenu : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
- // Button face + the active-target label (retail updates this to the chosen target).
- if (SpriteResolve is { } resolve)
+ var resolve = SpriteResolve;
+
+ // Button face + the active-target label.
+ if (resolve is not null)
{
var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite);
if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
- DrawLabel(ctx, ButtonText, 4f, (Height - LineH()) * 0.5f);
+ DrawLabel(ctx, ButtonText, 4f, (Height - LineH()) * 0.5f, TextColor);
- if (!_open) return;
+ if (!_open || resolve is null) return;
- // Two-column popup opening UPWARD from the button (chat sits at screen bottom).
- // Force OPAQUE: the menu must read solid even though the chat window is translucent.
+ // Two-column popup opening UPWARD from the button. Force OPAQUE (a menu reads
+ // solid even though the chat window is translucent). Draw the dat row sprites
+ // first, then the labels — both go through the sprite bucket in submission order,
+ // so the labels land on top (a DrawRect bg would composite over the text instead).
ctx.PushAlphaAbsolute(1f);
try
{
float top = -PopupH;
- ctx.DrawRect(0, top, PopupW, PopupH, PopupColor);
+ DrawSprite(ctx, resolve, PopupBgSprite, 0f, top, PopupW, PopupH); // panel base
for (int i = 0; i < Items.Length; i++)
{
int col = i / Rows, row = i % Rows;
- DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH);
+ float x = col * ColW, y = top + row * ItemH;
+ bool selected = Items[i].Channel is { } c && c == Selected;
+ DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH);
+ }
+ for (int i = 0; i < Items.Length; i++)
+ {
+ int col = i / Rows, row = i % Rows;
+ bool avail = Items[i].Channel is { } c && IsAvailable(c);
+ DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH,
+ avail ? TextColorAvailable : TextColorGhosted);
}
}
finally { ctx.PopAlpha(); }
@@ -110,10 +147,20 @@ public sealed class UiChannelMenu : UiElement
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
- private void DrawLabel(UiRenderContext ctx, string s, float x, float y)
+ private void DrawSprite(UiRenderContext ctx, Func resolve,
+ uint id, float x, float y, float w, float h)
{
- if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor);
- else ctx.DrawString(s, x, y, TextColor, Font);
+ if (id == 0) return;
+ var (tex, tw, th) = resolve(id);
+ if (tex == 0 || tw == 0 || th == 0) return;
+ // Tile at native size (the panel fill is 191×2; rows are 191×17 = 1:1).
+ ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
+ }
+
+ private void DrawLabel(UiRenderContext ctx, string s, float x, float y, Vector4 color)
+ {
+ if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, color);
+ else ctx.DrawString(s, x, y, color, Font);
}
protected override bool OnHitTest(float lx, float ly)
@@ -130,8 +177,9 @@ public sealed class UiChannelMenu : UiElement
int col = lx < ColW ? 0 : 1;
int row = (int)((ly + PopupH) / ItemH);
int idx = col * Rows + row;
+ // Only pick available channel items (special + greyed items are inert).
if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length
- && Items[idx].Channel is { } ch)
+ && Items[idx].Channel is { } ch && IsAvailable(ch))
{
Selected = ch;
OnChannelChanged?.Invoke(ch);
diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiChatScrollbar.cs
index 8c59f286..6d163ef3 100644
--- a/src/AcDream.App/UI/UiChatScrollbar.cs
+++ b/src/AcDream.App/UI/UiChatScrollbar.cs
@@ -89,10 +89,11 @@ public sealed class UiChatScrollbar : UiElement
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
- // Up button — top ButtonH rows (directional arrow art, drawn 1:1).
- DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
+ // Up button — top ButtonH rows. The dat up/down arrow sprites both point DOWN
+ // (confirmed by sprite export), so the TOP button is drawn V-FLIPPED to point UP.
+ DrawSpriteFlipV(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
- // Down button — bottom ButtonH rows.
+ // Down button — bottom ButtonH rows (down arrow as-is).
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
@@ -127,6 +128,17 @@ public sealed class UiChatScrollbar : UiElement
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
}
+ /// Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point
+ /// the top scroll button's (down-art) arrow upward.
+ private void DrawSpriteFlipV(UiRenderContext ctx, Func resolve,
+ uint id, float x, float y, float w, float h)
+ {
+ if (id == 0 || w <= 0f || h <= 0f) return;
+ var (tex, _, _) = resolve(id);
+ if (tex == 0) return;
+ ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One);
+ }
+
/// Draw a sprite TILED to fill the dest rect (UV-repeat at native size on
/// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1.
private void DrawTiled(UiRenderContext ctx, Func resolve,
diff --git a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs
index 59fe18f9..b3e9db8e 100644
--- a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs
@@ -6,10 +6,13 @@ namespace AcDream.App.Tests.UI;
public class UiChannelMenuTests
{
+ // PopupH = Rows(7) * ItemH(17) = 119; popup opens upward so top = -119.
+ // Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17).
+ // Right column needs lx >= ColW(191).
+
[Fact]
public void Items_HasExpected14Entries()
{
- // Retail gmMainChatUI::InitTalkFocusMenu: squelch + tell-selected + 12 channels.
Assert.Equal(14, UiChannelMenu.Items.Length);
}
@@ -17,7 +20,7 @@ public class UiChannelMenuTests
public void Items_FirstEntry_IsSquelch_Special()
{
Assert.Equal("Squelch (ignore)", UiChannelMenu.Items[0].Label);
- Assert.Null(UiChannelMenu.Items[0].Channel); // special item, no channel
+ Assert.Null(UiChannelMenu.Items[0].Channel);
}
[Fact]
@@ -46,30 +49,11 @@ public class UiChannelMenuTests
[Fact]
public void DefaultSelected_IsSay()
{
- var menu = new UiChannelMenu();
- Assert.Equal(ChatChannelKind.Say, menu.Selected);
+ Assert.Equal(ChatChannelKind.Say, new UiChannelMenu().Selected);
}
[Fact]
- public void Select_LeftColumnItem_FiresChannel()
- {
- var menu = new UiChannelMenu { Width = 80f, Height = 18f };
- var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); // open
- Assert.True(menu.OnEvent(openEvt));
-
- ChatChannelKind? fired = null;
- menu.OnChannelChanged = k => fired = k;
-
- // PopupH = 7*16 = 112, top = -112. "Chat to All" (Say) is index 2 = left col, row 2:
- // y in [-112+32, -112+48) = [-80,-64). Click (lx=10 < ColW, ly=-72) → idx 2 → Say.
- var selEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -72);
- Assert.True(menu.OnEvent(selEvt));
- Assert.Equal(ChatChannelKind.Say, fired);
- Assert.Equal(ChatChannelKind.Say, menu.Selected);
- }
-
- [Fact]
- public void Select_RightColumnItem_FiresChannel()
+ public void Select_AvailableLeftColumnItem_FiresChannel()
{
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
@@ -77,11 +61,25 @@ public class UiChannelMenuTests
ChatChannelKind? fired = null;
menu.OnChannelChanged = k => fired = k;
- // "Tell to Monarch" is index 7 = right col (lx >= ColW 150), row 0:
- // y in [-112, -96). Click (lx=160, ly=-104) → col 1, row 0 → idx 7 → Monarch.
- Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 160, -104)));
- Assert.Equal(ChatChannelKind.Monarch, fired);
- Assert.Equal(ChatChannelKind.Monarch, menu.Selected);
+ // "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available.
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76)));
+ Assert.Equal(ChatChannelKind.Say, fired);
+ Assert.Equal(ChatChannelKind.Say, menu.Selected);
+ }
+
+ [Fact]
+ public void Select_AvailableRightColumnItem_FiresChannel()
+ {
+ var menu = new UiChannelMenu { Width = 80f, Height = 18f };
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
+
+ ChatChannelKind? fired = null;
+ menu.OnChannelChanged = k => fired = k;
+
+ // "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34).
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42)));
+ Assert.Equal(ChatChannelKind.Trade, fired);
+ Assert.Equal(ChatChannelKind.Trade, menu.Selected);
}
[Fact]
@@ -89,12 +87,39 @@ public class UiChannelMenuTests
{
var menu = new UiChannelMenu { Width = 80f, Height = 18f };
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
-
int fired = 0;
menu.OnChannelChanged = _ => fired++;
- // "Squelch (ignore)" is index 0 = left col, row 0: y in [-112, -96). No channel.
- Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -104)));
- Assert.Equal(0, fired); // special item is a no-op
+ // "Squelch (ignore)" is index 0 = left col, row 0 (null channel): y in [-119,-102).
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110)));
+ Assert.Equal(0, fired);
+ }
+
+ [Fact]
+ public void Select_UnavailableChannel_DoesNotFire()
+ {
+ var menu = new UiChannelMenu { Width = 80f, Height = 18f };
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
+ int fired = 0;
+ menu.OnChannelChanged = _ => fired++;
+
+ // "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51).
+ // Fellowship is unavailable by the default static gate, so the click is inert.
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
+ Assert.Equal(0, fired);
+ }
+
+ [Fact]
+ public void AvailabilityProvider_Overrides_DefaultGate()
+ {
+ var menu = new UiChannelMenu { Width = 80f, Height = 18f, AvailabilityProvider = _ => true };
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
+
+ ChatChannelKind? fired = null;
+ menu.OnChannelChanged = k => fired = k;
+
+ // With every channel available, "Tell to Fellows" (idx 3, row 3) now fires.
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
+ Assert.Equal(ChatChannelKind.Fellowship, fired);
}
}