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

@ -123,6 +123,17 @@ public class ChatWindowControllerTests
info.StateMedia["Highlight"] = (0x2u, 1);
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 indicator2 = MakeIndicator(0x10000523u, 22);
var indicator3 = MakeIndicator(0x10000524u, 39);
@ -135,6 +146,7 @@ public class ChatWindowControllerTests
root.Children.Add(transcriptPanel);
root.Children.Add(inputBar);
root.Children.Add(maxMinNode);
root.Children.Add(unread);
root.Children.Add(indicator1);
root.Children.Add(indicator2);
root.Children.Add(indicator3);
@ -187,6 +199,42 @@ public class ChatWindowControllerTests
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]
public void ClickingTheIndicatorJumpsToTheNewestText()
{