feat(ui): authored state media animates, so the unseen-text indicator blinks

The blink is not code. It is data, and we were throwing it away.

A retail UI state's media is a small program: images interleaved with timed
pauses, branches, and a terminal hand-off to another state. Our importer kept
the FIRST image per state and dropped the rest, so nothing authored could ever
animate — the indicator was correct in every other respect and simply sat
still.

Measured from the installed dats (LayoutDump --media 0x1000048C), the chat
unseen-text indicator's Normal state authors thirteen steps: two frames
alternating every half second, three times, then `State 13` — Ghosted, whose
authored 0x3B is Invisible.

So retail's indicator is a three-second attention FLASH that hides itself, not
a badge that stays lit until you scroll to the bottom. Nobody would guess that
from the code, because there is no blink code anywhere; the behaviour lives
entirely in the authored sequence. Our shipped version stayed lit, which is
the one thing the data says it must not do.

Sampling is a pure function of (steps, elapsed) rather than a playback object
holding a cursor, so an element only has to remember WHEN its state began and
the whole thing is testable without a clock, a GPU or a frame loop. One shared
UiMediaClock is advanced once per frame by RetailUiRuntime; a UI element has
no tick of its own.

The controller change is the other half: it starts the flash on the rising
edge ONLY. Re-setting Normal every frame would pin the sequence on frame zero
and it would never blink at all — which is the failure mode the second new
test exists to catch, and which no "is it visible?" assertion would notice.
When the sequence reaches its terminal step the controller follows it down
instead of re-lighting it.

Two guesses are refused rather than made, and both are registered: a Pause's
max duration (every sequence measured sets min == max, and what the range MEANS
is not in the decomp) and a sub-1 branch probability (falls through, the
direction where a malformed sequence stops rather than animates forever).
A jump-cycle with no elapsed time is bounded so a bad sequence cannot spin
inside a frame.

Kept `Other` steps in the list rather than filtering them, so a jump's authored
index still lands on the entry it names.

Register: CT-3, CT-4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 11:14:53 +02:00
parent f44f7641b1
commit 89db9a794c
10 changed files with 557 additions and 56 deletions

View file

@ -332,7 +332,22 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches <see cref="UiDatElement.ActiveState"/>.
/// </summary>
public string ActiveState { get; set; } = "";
private string _activeState = "";
private double _activeStateStartedAt;
public string ActiveState
{
get => _activeState;
set
{
if (string.Equals(_activeState, value, StringComparison.Ordinal))
return;
_activeState = value;
// An authored media sequence is timed from the moment its state is
// entered, so this is the only thing an element needs to remember.
_activeStateStartedAt = Layout.UiMediaClock.Seconds;
}
}
public uint ActiveRetailStateId
{
@ -592,18 +607,64 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
private static uint ActiveFile(ElementInfo mediaInfo, string mediaState)
=> mediaInfo.StateMedia.TryGetValue(mediaState, out var m) ? m.File : 0u;
/// <summary>
/// The frame this element's active state is showing right now, and the
/// state its sequence hands off to when it ends.
/// </summary>
/// <remarks>
/// A state whose media is a single image is NOT routed through the player;
/// it keeps the still-frame path, so the overwhelming majority of buttons
/// are untouched by this.
/// </remarks>
private uint AnimatedFile(ElementInfo mediaInfo, string mediaState, out uint? handOff)
{
handOff = null;
if (!TryFindStateNamed(mediaInfo, mediaState, out UiStateInfo? state)
|| !Layout.UiMediaSequence.IsAnimated(state!.MediaSteps))
{
return ActiveFile(mediaInfo, mediaState);
}
(uint file, uint? transition) = Layout.UiMediaSequence.Sample(
state.MediaSteps,
(float)(Layout.UiMediaClock.Seconds - _activeStateStartedAt));
handOff = transition;
return file != 0u ? file : ActiveFile(mediaInfo, mediaState);
}
private static bool TryFindStateNamed(
ElementInfo mediaInfo, string mediaState, out UiStateInfo? state)
{
foreach (var (_, candidate) in mediaInfo.States)
{
if (string.Equals(candidate.Name, mediaState, StringComparison.Ordinal))
{
state = candidate;
return true;
}
}
state = null;
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
SyncMediaStates();
// An authored media sequence can end by handing the element to another
// state (the chat unseen-text indicator blinks three times, then hands
// off to Ghosted). Collected here and applied AFTER the draw: changing
// state mid-draw would invalidate the very media being drawn.
uint? pendingHandOff = null;
if (_faceSegments.Length != 0)
{
for (int i = 0; i < _faceSegments.Length; i++)
{
FaceSegment segment = _faceSegments[i];
DrawFace(
ctx,
ActiveFile(segment.Info, _segmentMediaStates[i]),
segment.Rect(Width, Height));
uint frame = AnimatedFile(
segment.Info, _segmentMediaStates[i], out uint? segmentHandOff);
DrawFace(ctx, frame, segment.Rect(Width, Height));
pendingHandOff ??= segmentHandOff;
}
}
else if (ColorKeyFaceResolver is { } colorKeyResolver)
@ -624,7 +685,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
else
{
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo, _faceMediaState);
uint file = FaceFileOverride
?? AnimatedFile(_mediaInfo, _faceMediaState, out pendingHandOff);
if (file != 0)
{
var (tex, tw, th) = _resolve(file);
@ -640,6 +702,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
}
// The sequence has run out and asked for another state. Applied here,
// after every face has drawn this frame.
if (pendingHandOff is { } handOffState)
TrySetRetailState(handOffState);
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
// GF-11c: LabelBox null (every pre-existing button) reduces boxX/