fix(chat): the unseen-text indicator is state-driven, as retail drives it

User report: "the Unseen indicator shows, but not blinking. I thought it was
blinking in retail." They were right, and CT-C1 had the mechanism wrong.

The dat settles it. Element 0x1000048C authors:

    1:Normal      media=13/6     <- SIX image frames: the flash
    3:Normal_pressed  media=2/1
    13:Ghosted    media=0/0      <- the authored DEFAULT, draws nothing

and retail's own click handler ends in SetState(0xD) — Ghosted. So the
indicator is driven by authored STATE, never by visibility, and the blinking is
a multi-frame media list in the DATA rather than anything in code.

CT-C1 toggled Visible instead. That looks almost right — the thing appears and
disappears at the correct moments — and can never blink, because visibility has
no frames. Now switched to Normal/Ghosted, which is both the retail mechanism
and the thing the animation hangs off.

STILL NOT BLINKING, and honestly so: our importer keeps ONE image per state
(ElementInfo.StateMedia is a single file), so multi-frame media is not modelled
anywhere in the UI layer. That is a capability rather than a tweak — the same
shape as the tagged-runs work in Group A — and the state machinery here is
correct either way, so it gains the animation for free once that lands. Recorded
in the method's own doc rather than left as a mystery.

The test fixture gained the element: it was absent, so the whole binding path
had never been exercised by any test — which is why a visibility-based
implementation passed everything. The test now asserts the state TRANSITIONS
(Ghosted at rest, Normal when a line arrives while scrolled up, Ghosted again on
returning to the bottom), not merely that something was bound.

Two notes on reading the decomp here, since both nearly misled me. Binary
Ninja's field names in this function are demonstrably shifted — it assigns a
UIElement* into m_fCurrentOpacity, a float — so the element's ROLE was
confirmed from its id and its click handler, not from a name. And the blink was
found by measuring the dat, not by reading code, because there is no blink code
to read.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 10:23:15 +02:00
parent 111fbba3bb
commit cbab79d70c
2 changed files with 87 additions and 3 deletions

View file

@ -153,6 +153,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private UiElement? _unreadIndicator; private UiElement? _unreadIndicator;
/// <summary>Test seam: the bound unseen-text indicator, if the layout has one.</summary>
internal UiElement? UnreadIndicatorForTest => _unreadIndicator;
/// <summary>Test seam: force the unseen flag without faking a line arrival.</summary>
internal void SetUnreadForTest(bool unread) => _hasUnseenText = unread;
/// <summary> /// <summary>
/// Set when a line arrives while the transcript is scrolled up, cleared /// Set when a line arrives while the transcript is scrolled up, cleared
/// the moment the view is back at the bottom. /// the moment the view is back at the bottom.
@ -372,7 +378,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
c._unreadIndicator = layout.FindElement(UnreadIndicatorId); c._unreadIndicator = layout.FindElement(UnreadIndicatorId);
if (c._unreadIndicator is not null) if (c._unreadIndicator is not null)
{ {
c._unreadIndicator.Visible = false; // Driven by authored STATE, not visibility. Retail's own click
// handler ends in SetState(0xD) — Ghosted — which is also the
// element's authored default, and the unread look is state 1
// (Normal). Ghosted authors no media at all, so it draws nothing
// without needing to be hidden.
c.SetUnreadIndicatorState(unread: false);
if (c._unreadIndicator is UiButton unread) if (c._unreadIndicator is UiButton unread)
unread.OnClick = c.ScrollToNewestAndClearUnread; unread.OnClick = c.ScrollToNewestAndClearUnread;
} }
@ -909,10 +920,35 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
{ {
if (Transcript.Scroll.AtEnd) if (Transcript.Scroll.AtEnd)
_hasUnseenText = false; _hasUnseenText = false;
if (_unreadIndicator is not null) SetUnreadIndicatorState(_hasUnseenText);
_unreadIndicator.Visible = _hasUnseenText;
} }
/// <summary>
/// Puts the indicator into its authored unread (<c>Normal</c>) or idle
/// (<c>Ghosted</c>) state.
/// </summary>
/// <remarks>
/// <b>The blink is authored, not coded.</b> The element's Normal state
/// carries SIX image frames (measured: <c>LayoutDump --states</c> on
/// 0x2100006F reports <c>media=13/6</c> for state 1, against <c>0/0</c>
/// for Ghosted), so retail's flashing comes from cycling that media list.
/// Our importer keeps only ONE file per state
/// (<see cref="Layout.ElementInfo.StateMedia"/>), so the indicator
/// currently shows a static frame. Multi-frame state media is its own
/// capability; the state machinery here is right either way, and gains the
/// animation for free once that lands.
/// </remarks>
private void SetUnreadIndicatorState(bool unread)
{
if (_unreadIndicator is not IUiDatStateful stateful)
return;
stateful.TrySetRetailState(
unread ? UiButtonStateMachine.Normal : GhostedStateId);
}
/// <summary>Retail state <c>0xD</c>, the id its own click handler sets.</summary>
private const uint GhostedStateId = 13u;
/// <summary>Aims the chat entry at <paramref name="name"/> and focuses it.</summary> /// <summary>Aims the chat entry at <paramref name="name"/> and focuses it.</summary>
internal void StartTell(string name) internal void StartTell(string name)
{ {

View file

@ -123,6 +123,17 @@ public class ChatWindowControllerTests
info.StateMedia["Highlight"] = (0x2u, 1); info.StateMedia["Highlight"] = (0x2u, 1);
return info; return info;
} }
// The unseen-text indicator (CT-C1). Authors Normal and Ghosted media
// the way the real element does, so TrySetRetailState can resolve both
// — Ghosted is its authored DEFAULT and is what retail's own click
// handler sets (SetState(0xD)).
var unread = new ElementInfo
{
Id = 0x1000048Cu, Type = 1, X = 0, Y = 57, Width = 16, Height = 16,
};
unread.StateMedia["Normal"] = (0x3u, 1);
unread.StateMedia["Ghosted"] = (0x4u, 1);
var indicator1 = MakeIndicator(0x10000522u, 5); var indicator1 = MakeIndicator(0x10000522u, 5);
var indicator2 = MakeIndicator(0x10000523u, 22); var indicator2 = MakeIndicator(0x10000523u, 22);
var indicator3 = MakeIndicator(0x10000524u, 39); var indicator3 = MakeIndicator(0x10000524u, 39);
@ -135,6 +146,7 @@ public class ChatWindowControllerTests
root.Children.Add(transcriptPanel); root.Children.Add(transcriptPanel);
root.Children.Add(inputBar); root.Children.Add(inputBar);
root.Children.Add(maxMinNode); root.Children.Add(maxMinNode);
root.Children.Add(unread);
root.Children.Add(indicator1); root.Children.Add(indicator1);
root.Children.Add(indicator2); root.Children.Add(indicator2);
root.Children.Add(indicator3); root.Children.Add(indicator3);
@ -187,6 +199,42 @@ public class ChatWindowControllerTests
Assert.True(ctrl.Transcript.Scroll.AtEnd); Assert.True(ctrl.Transcript.Scroll.AtEnd);
} }
[Fact]
public void TheIndicatorIsDrivenByAuthoredStateNotVisibility()
{
// Retail's own click handler ends in SetState(0xD) — Ghosted — which
// is also the element's authored default; the unread look is state 1
// (Normal), whose media list carries SIX frames and is where the
// flashing comes from. Hiding the element instead would look almost
// right and could never blink.
ChatWindowController ctrl = BindController();
UiElement indicator = Assert.IsAssignableFrom<UiElement>(
ctrl.UnreadIndicatorForTest);
// Visibility is NOT the mechanism: the element stays visible and
// changes STATE. Ghosted authors no media on the real element, so it
// draws nothing without being hidden.
Assert.True(indicator.Visible);
var stateful = Assert.IsAssignableFrom<IUiDatStateful>(indicator);
Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState);
// A line arriving while scrolled up flips it to Normal — the state
// whose authored media carries the six flash frames.
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0);
ctrl.SetUnreadForTest(true);
ctrl.UpdateUnreadIndicator();
Assert.Equal("Normal", ((UiButton)indicator).ActiveState);
// ...and returning to the bottom puts it back.
ctrl.Transcript.Scroll.ScrollToEnd();
ctrl.UpdateUnreadIndicator();
Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState);
_ = stateful;
}
[Fact] [Fact]
public void ClickingTheIndicatorJumpsToTheNewestText() public void ClickingTheIndicatorJumpsToTheNewestText()
{ {