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:
parent
f44f7641b1
commit
89db9a794c
10 changed files with 557 additions and 56 deletions
|
|
@ -938,29 +938,46 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
/// capability; the state machinery here is right either way, and gains the
|
||||
/// animation for free once that lands.
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// True once the authored flash has been started for the current batch of
|
||||
/// unseen text, so it is not restarted every frame.
|
||||
/// </summary>
|
||||
private bool _flashStarted;
|
||||
|
||||
private void SetUnreadIndicatorState(bool unread)
|
||||
{
|
||||
if (_unreadIndicator is null)
|
||||
return;
|
||||
|
||||
// Apply the element's OWN authored per-state visibility (dat property
|
||||
// 0x3B, "Invisible"), measured on 0x1000048C as:
|
||||
//
|
||||
// state 13 Ghosted 0x3B = True -> hidden
|
||||
// state 1 Normal 0x3B = False -> shown
|
||||
//
|
||||
// UiDatElement applies 0x3B on a state change; UiButton does not, and
|
||||
// this element builds as a button. So the property is applied here
|
||||
// rather than left unhonoured — this is the authored data, not a
|
||||
// visibility hack layered over it.
|
||||
_unreadIndicator.Visible = unread;
|
||||
if (!unread)
|
||||
{
|
||||
_unreadIndicator.Visible = false;
|
||||
_flashStarted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_flashStarted)
|
||||
{
|
||||
// Rising edge: start the authored sequence. Set ONCE — restarting
|
||||
// it every frame would hold it on frame zero and it would never
|
||||
// appear to blink at all.
|
||||
_flashStarted = true;
|
||||
_unreadIndicator.Visible = true;
|
||||
if (_unreadIndicator is IUiDatStateful starting)
|
||||
starting.TrySetRetailState(UiButtonStateMachine.Normal);
|
||||
return;
|
||||
}
|
||||
|
||||
// The sequence ENDS itself: after three blinks it hands off to
|
||||
// Ghosted, whose authored 0x3B is Invisible. Follow that rather than
|
||||
// holding the indicator lit — retail's is a transient attention-flash,
|
||||
// not a badge that stays up until you scroll down.
|
||||
if (_unreadIndicator is UiButton flashing
|
||||
&& string.Equals(flashing.ActiveState, "Ghosted", StringComparison.Ordinal))
|
||||
{
|
||||
_unreadIndicator.Visible = false;
|
||||
}
|
||||
|
||||
// Set the state too, for the media it selects. Deliberately only on
|
||||
// the way IN: TrySetRetailState(Ghosted) means Enabled = false, and
|
||||
// disabling the button would also refuse the click that scrolls to
|
||||
// the newest text.
|
||||
if (unread && _unreadIndicator is IUiDatStateful stateful)
|
||||
stateful.TrySetRetailState(UiButtonStateMachine.Normal);
|
||||
}
|
||||
|
||||
/// <summary>Aims the chat entry at <paramref name="name"/> and focuses it.</summary>
|
||||
|
|
|
|||
|
|
@ -609,6 +609,31 @@ public static class LayoutImporter
|
|||
MediaCount = sd.Media.Count,
|
||||
};
|
||||
|
||||
// Keep the WHOLE sequence, in order. A state's media is a small
|
||||
// program — images interleaved with pauses and jumps — and taking only
|
||||
// the first image (below) reduces a blinking element to a still frame.
|
||||
// Unrecognised entries are kept as Other so a jump's index still lands
|
||||
// on the authored entry.
|
||||
var steps = new List<UiMediaStep>(sd.Media.Count);
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
steps.Add(m switch
|
||||
{
|
||||
MediaDescImage i => new UiMediaStep(
|
||||
UiMediaStepKind.Image, i.File, (int)i.DrawMode, 0f, 0f, 0u, 0f),
|
||||
MediaDescPause p => new UiMediaStep(
|
||||
UiMediaStepKind.Pause, 0u, 0, p.MinDuration, p.MaxDuration, 0u, 0f),
|
||||
MediaDescState st => new UiMediaStep(
|
||||
UiMediaStepKind.State, 0u, 0, 0f, 0f,
|
||||
(uint)st.StateId, st.Probability),
|
||||
MediaDescJump j => new UiMediaStep(
|
||||
UiMediaStepKind.Jump, 0u, 0, 0f, 0f, j.JumpItemIndex, j.Probability),
|
||||
_ => new UiMediaStep(
|
||||
UiMediaStepKind.Other, 0u, 0, 0f, 0f, 0u, 0f, (int)m.MediaType),
|
||||
});
|
||||
}
|
||||
state.MediaSteps = steps;
|
||||
|
||||
bool imageRead = false;
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
|
|
|
|||
158
src/AcDream.App/UI/Layout/UiMediaSequence.cs
Normal file
158
src/AcDream.App/UI/Layout/UiMediaSequence.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// The clock authored media animations are sampled against.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A single shared clock rather than a timer per element: sampling is a pure
|
||||
/// function of (steps, elapsed), so an element only needs to remember WHEN its
|
||||
/// state began. Advanced once per frame by the host — a UI element has no tick
|
||||
/// of its own.
|
||||
/// </remarks>
|
||||
public static class UiMediaClock
|
||||
{
|
||||
/// <summary>Seconds since the host started advancing this clock.</summary>
|
||||
public static double Seconds { get; private set; }
|
||||
|
||||
public static void Advance(double deltaSeconds)
|
||||
{
|
||||
if (double.IsFinite(deltaSeconds) && deltaSeconds > 0d)
|
||||
Seconds += deltaSeconds;
|
||||
}
|
||||
|
||||
/// <summary>Test seam: rewind to a known point.</summary>
|
||||
internal static void ResetForTest() => Seconds = 0d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays a retail UI state's media sequence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A state's media is a small program rather than a picture: images
|
||||
/// interleaved with timed pauses, branches, and a terminal state hand-off.
|
||||
/// The chat window's unseen-text indicator (<c>0x1000048C</c>) authors this,
|
||||
/// measured from the installed dats:
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// [ 0] Image 0x06005F0E [ 1] Pause 0.5
|
||||
/// [ 2] Image 0x06005F0F [ 3] Pause 0.5 two frames alternating,
|
||||
/// ... three times over three seconds
|
||||
/// [12] State 13 (Ghosted) p=1 then it hides itself
|
||||
/// </code>
|
||||
/// <para>
|
||||
/// So retail's indicator is a transient attention-flash, not a badge that
|
||||
/// stays lit. That is behaviour nobody would guess from the code, because
|
||||
/// there is no blink code — it is entirely in the data.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Sampling is a pure function of (steps, elapsed): no playback object holds
|
||||
/// a cursor, so a caller only has to remember WHEN a state began. That keeps
|
||||
/// the animation testable without a clock, a GPU or a frame loop.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class UiMediaSequence
|
||||
{
|
||||
/// <summary>
|
||||
/// Guards a malformed sequence whose jumps form a cycle with no elapsed
|
||||
/// time — without it, such a sequence would spin forever inside one frame.
|
||||
/// </summary>
|
||||
private const int MaximumSteps = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="steps"/> does anything over time. A state whose
|
||||
/// media is a single image is NOT an animation and must keep the ordinary
|
||||
/// still-frame path.
|
||||
/// </summary>
|
||||
public static bool IsAnimated(IReadOnlyList<UiMediaStep>? steps)
|
||||
{
|
||||
if (steps is null || steps.Count < 2)
|
||||
return false;
|
||||
|
||||
int images = 0;
|
||||
foreach (UiMediaStep step in steps)
|
||||
{
|
||||
switch (step.Kind)
|
||||
{
|
||||
case UiMediaStepKind.Pause:
|
||||
case UiMediaStepKind.Jump:
|
||||
case UiMediaStepKind.State:
|
||||
return true;
|
||||
case UiMediaStepKind.Image when ++images > 1:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The frame showing at <paramref name="elapsedSeconds"/>, and the state
|
||||
/// the sequence hands off to if it has reached its end.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>File</c> is 0 when no image has been reached yet.
|
||||
/// <c>TransitionState</c> is null until a terminal State step is due.
|
||||
/// </returns>
|
||||
public static (uint File, uint? TransitionState) Sample(
|
||||
IReadOnlyList<UiMediaStep>? steps,
|
||||
float elapsedSeconds)
|
||||
{
|
||||
if (steps is null || steps.Count == 0)
|
||||
return (0u, null);
|
||||
|
||||
uint file = 0u;
|
||||
float at = 0f;
|
||||
int cursor = 0;
|
||||
|
||||
for (int guard = 0; guard < MaximumSteps; guard++)
|
||||
{
|
||||
if (cursor < 0 || cursor >= steps.Count)
|
||||
return (file, null);
|
||||
|
||||
UiMediaStep step = steps[cursor];
|
||||
switch (step.Kind)
|
||||
{
|
||||
case UiMediaStepKind.Image:
|
||||
file = step.File;
|
||||
cursor++;
|
||||
break;
|
||||
|
||||
case UiMediaStepKind.Pause:
|
||||
// Retail authors a min and a max; every sequence measured
|
||||
// so far sets them equal. MinDuration is used, and the
|
||||
// range is left unimplemented rather than guessed at —
|
||||
// see the divergence register.
|
||||
at += Math.Max(0f, step.MinDuration);
|
||||
if (elapsedSeconds < at)
|
||||
return (file, null); // still holding this frame
|
||||
cursor++;
|
||||
break;
|
||||
|
||||
case UiMediaStepKind.Jump:
|
||||
// A probability below 1 is a chance to branch. Treated as
|
||||
// "always" would loop a sequence retail sometimes lets
|
||||
// fall through, so anything uncertain falls through here
|
||||
// instead — the conservative direction, since a sequence
|
||||
// that ends early stops animating rather than animating
|
||||
// forever.
|
||||
if (step.Probability >= 1f)
|
||||
cursor = (int)step.JumpIndex;
|
||||
else
|
||||
cursor++;
|
||||
break;
|
||||
|
||||
case UiMediaStepKind.State:
|
||||
return (file, step.Probability >= 1f ? step.JumpIndex : null);
|
||||
|
||||
default:
|
||||
cursor++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (file, null);
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +110,45 @@ public sealed class UiPropertyBag
|
|||
/// <summary>Primary render-surface media for a retail UI state.</summary>
|
||||
public readonly record struct UiImageMedia(uint File, int DrawMode);
|
||||
|
||||
/// <summary>What one entry of a state's media sequence does.</summary>
|
||||
public enum UiMediaStepKind
|
||||
{
|
||||
/// <summary>Anything we do not act on (sound, movie, message, ...).</summary>
|
||||
Other,
|
||||
|
||||
/// <summary>Show this image.</summary>
|
||||
Image,
|
||||
|
||||
/// <summary>Hold the current image for a duration.</summary>
|
||||
Pause,
|
||||
|
||||
/// <summary>Branch to another entry — what makes a sequence loop.</summary>
|
||||
Jump,
|
||||
|
||||
/// <summary>Hand the element to another STATE when the sequence ends.</summary>
|
||||
State,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry of a retail state's media list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A state's media is a SEQUENCE, not a picture: images interleaved with
|
||||
/// pauses and jumps, which is how retail authors a blinking or cycling
|
||||
/// element. Unrecognised entries are kept as
|
||||
/// <see cref="UiMediaStepKind.Other"/> rather than dropped, so a jump's
|
||||
/// index still lands on the right entry.
|
||||
/// </remarks>
|
||||
public readonly record struct UiMediaStep(
|
||||
UiMediaStepKind Kind,
|
||||
uint File,
|
||||
int DrawMode,
|
||||
float MinDuration,
|
||||
float MaxDuration,
|
||||
uint JumpIndex,
|
||||
float Probability,
|
||||
int RawType = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Dat-independent state descriptor. DirectState uses
|
||||
/// <see cref="DirectStateId"/> so it cannot collide with <c>UIStateId.Undef == 0</c>.
|
||||
|
|
@ -123,6 +162,12 @@ public sealed class UiStateInfo
|
|||
public bool PassToChildren;
|
||||
public uint IncorporationFlags;
|
||||
public UiImageMedia? Image;
|
||||
|
||||
/// <summary>
|
||||
/// The state's media list in authored order, or empty when it has none.
|
||||
/// <see cref="Image"/> remains the first drawable image — the still frame.
|
||||
/// </summary>
|
||||
public IReadOnlyList<UiMediaStep> MediaSteps = Array.Empty<UiMediaStep>();
|
||||
public UiCursorMedia? Cursor;
|
||||
public UiPropertyBag Properties = new();
|
||||
|
||||
|
|
@ -155,6 +200,7 @@ public sealed class UiStateInfo
|
|||
PassToChildren = PassToChildren,
|
||||
IncorporationFlags = IncorporationFlags,
|
||||
Image = Image,
|
||||
MediaSteps = MediaSteps,
|
||||
Cursor = Cursor,
|
||||
Properties = Properties.Clone(),
|
||||
MediaCount = MediaCount,
|
||||
|
|
|
|||
|
|
@ -851,6 +851,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
// Authored media sequences (a blinking indicator, any cycling element)
|
||||
// are sampled against this one clock — UI elements have no tick.
|
||||
Layout.UiMediaClock.Advance(deltaSeconds);
|
||||
FpsController?.Tick();
|
||||
_vividTargetIndicator?.Tick();
|
||||
_vitalsSideBySide?.Tick();
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue