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

@ -1,4 +1,4 @@
# Retail Divergence Register — current through 2026-07-31
# Retail Divergence Register — current through 2026-07-31
**What this is.** The single auditable register of every known place acdream's
runtime behavior can deviate from the retail client (Sept 2013 EoR build,
@ -492,6 +492,8 @@ equivalence argument (promote to AD/AP) or a fix.
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)``1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
| CT-1 | Transcript truncation uses ONE character threshold (10,000) where retail uses two — it beheads to ~7,500 (`0x1D4C`) on passing 10,000 (`0x2710`), so its buffer oscillates between the two. acdream also cuts at whole LINES rather than searching for a newline near a byte offset | `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`MaxTranscriptCharacters`, `FirstLineWithinBudget`) | Retail's hysteresis exists to avoid re-trimming an ACCUMULATING buffer on every append; we rebuild the visible list from the log each time, so there is nothing to damp and a second threshold would only make the oldest visible line jump around as messages arrive. Whole-line cutting is what retail's newline preference is trying to achieve — our unit already is the line | acdream shows up to ~2,500 characters more scrollback than retail at the moment retail has just trimmed. Visible only as a slightly longer history; no state, wire or memory effect (ChatLog's own entry cap still bounds the model) | `ChatInterface::TruncateChatLog @0x004F4290`; threshold read at `RecvNotice_DisplayFinalStringInfo @0x004F4640` |
| CT-2 | No client-side chat word filtering. Retail runs every transcript line through a taboo table when the `FilterLanguage` option is on and SUBSTITUTES matches; acdream performs no substitution at all. The option itself is kept and still stores/ships its bit to the server exactly as retail does | `src/AcDream.Core.Net/GameEventWiring.cs` (no filter in the AddText path); option at `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | DELIBERATE PRODUCT DECISION by the user, 2026-08-21: "I do not want any censoring." Not an oversight and not a porting gap | A player who enables FilterLanguage expecting retail's behaviour sees unfiltered text. No state, wire or server-visible effect — the option bit is still sent, so anything the SERVER gates on it behaves normally | `PlayerModule::FilterLanguage` + `TabooTableAdaptor::CheckCensorsW @0x00682A30` inside `ClientSystem::AddTextToScroll @0x00563C50`; matching at `TabooTable::CreateCheckString @0x00681570` / `StringMatchesFilter @0x00681600` |
| CT-3 | A media `Pause` step holds for its `MinDuration`; retail authors a min AND a max and acdream ignores the max. Every sequence measured so far sets them equal, so nothing shipped is affected | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`, the `Pause` case) | Whether the range means a random hold, a ramp, or a min-with-a-frame-budget ceiling is NOT determinable from the decomp, and picking one would be a guess dressed as a port. Using the min is the one reading that is right in every interpretation for the equal-valued case we can actually observe | A sequence authoring min != max would animate faster than retail. None does in the elements dumped so far; if one is found, the reading has to be measured before it is implemented | `MediaDescPause` in the LayoutDesc dat; playback at `UIElement::AnimateMedia` |
| CT-4 | A media `Jump`/`State` step with a probability below 1 FALLS THROUGH rather than branching; retail rolls for it | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`) | The roll's distribution and its re-roll cadence (per visit? per state entry?) are not in the decomp. Falling through is the conservative direction: a sequence that ends early stops animating, where treating it as certain would animate forever and could pin a state that never hands off | A probabilistic sequence plays its deterministic tail instead of its branch. The chat indicator authors p=1 throughout, so it is exact there | `MediaDescJump{Probability}` / `MediaDescState{Probability}` in the LayoutDesc dat |
---

View file

@ -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>

View file

@ -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)
{

View 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);
}
}

View file

@ -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,

View file

@ -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();

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/

View file

@ -228,6 +228,57 @@ public class ChatWindowControllerTests
Assert.False(indicator.Visible); // back to Ghosted
}
[Fact]
public void TheAuthoredHandOffEndsTheFlashEvenWhileStillScrolledUp()
{
// The blink is not code, it is DATA: the Normal state's media authors
// six image frames, three pauses' worth of alternation, and then a
// State step back to Ghosted (measured with LayoutDump --media on
// 0x1000048C). So retail's indicator is a three-second attention
// FLASH that hides itself — not a badge that stays lit until you
// scroll down. The controller has to let the sequence finish rather
// than re-lighting it every frame.
ChatWindowController ctrl = BindController();
var indicator = Assert.IsType<UiButton>(ctrl.UnreadIndicatorForTest);
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0);
ctrl.SetUnreadForTest(true);
ctrl.UpdateUnreadIndicator();
Assert.True(indicator.Visible);
// Stand in for the sequence reaching its terminal State step.
indicator.TrySetRetailState(UiButtonStateMachine.Ghosted);
ctrl.UpdateUnreadIndicator();
Assert.False(indicator.Visible);
Assert.False(ctrl.Transcript.Scroll.AtEnd); // still scrolled up
}
[Fact]
public void TheFlashIsStartedOnceRatherThanEveryFrame()
{
// Re-setting Normal every frame would restart the sequence, pinning it
// on frame zero — it would sit there lit and never blink at all. That
// is the whole bug this pins, and it is invisible to a test that only
// checks the indicator is showing.
ChatWindowController ctrl = BindController();
var indicator = Assert.IsType<UiButton>(ctrl.UnreadIndicatorForTest);
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
ctrl.Transcript.Scroll.SetScrollY(0);
ctrl.SetUnreadForTest(true);
ctrl.UpdateUnreadIndicator();
// Mid-sequence the player owns the state; the controller must not
// touch it again until the unseen flag is cleared and re-raised.
indicator.TrySetRetailState(UiButtonStateMachine.Ghosted);
for (int frame = 0; frame < 5; frame++)
ctrl.UpdateUnreadIndicator();
Assert.Equal("Ghosted", indicator.ActiveState);
}
[Fact]
public void ClickingTheIndicatorJumpsToTheNewestText()
{

View file

@ -0,0 +1,139 @@
using System.Collections.Generic;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Retail's state media is a small program, and this plays it.
/// </summary>
public sealed class UiMediaSequenceTests
{
private static UiMediaStep Image(uint file)
=> new(UiMediaStepKind.Image, file, 1, 0f, 0f, 0u, 0f);
private static UiMediaStep Pause(float seconds)
=> new(UiMediaStepKind.Pause, 0u, 0, seconds, seconds, 0u, 0f);
private static UiMediaStep State(uint state, float probability = 1f)
=> new(UiMediaStepKind.State, 0u, 0, 0f, 0f, state, probability);
private static UiMediaStep Jump(uint index, float probability = 1f)
=> new(UiMediaStepKind.Jump, 0u, 0, 0f, 0f, index, probability);
/// <summary>
/// The chat unseen-text indicator's authored sequence, verbatim from the
/// installed dats (0x2100006F / 0x1000048C, state 1): two frames
/// alternating every half second, three times, then hand off to Ghosted.
/// </summary>
private static IReadOnlyList<UiMediaStep> BlinkSequence() =>
[
Image(0x06005F0Eu), Pause(0.5f),
Image(0x06005F0Fu), Pause(0.5f),
Image(0x06005F0Eu), Pause(0.5f),
Image(0x06005F0Fu), Pause(0.5f),
Image(0x06005F0Eu), Pause(0.5f),
Image(0x06005F0Fu), Pause(0.5f),
State(13u),
];
[Theory]
[InlineData(0.0f, 0x06005F0Eu)]
[InlineData(0.4f, 0x06005F0Eu)]
[InlineData(0.5f, 0x06005F0Fu)] // first flip, exactly on the boundary
[InlineData(0.9f, 0x06005F0Fu)]
[InlineData(1.0f, 0x06005F0Eu)]
[InlineData(2.75f, 0x06005F0Fu)]
public void TheFrameAlternatesEveryHalfSecond(float elapsed, uint expected)
{
(uint file, uint? transition) = UiMediaSequence.Sample(BlinkSequence(), elapsed);
Assert.Equal(expected, file);
Assert.Null(transition);
}
[Fact]
public void AfterThreeSecondsItHandsOffToGhosted()
{
// This is the behaviour nobody would guess from the code: retail's
// indicator is a transient attention-flash, not a badge that stays
// lit. Three seconds of blinking, then it hides itself.
(uint _, uint? transition) = UiMediaSequence.Sample(BlinkSequence(), 3.0f);
Assert.Equal(13u, transition);
}
[Fact]
public void TheHandOffDoesNotFireEarly()
{
Assert.Null(UiMediaSequence.Sample(BlinkSequence(), 2.99f).TransitionState);
}
[Fact]
public void ASingleImageIsNotAnAnimation()
{
// A still frame must keep the ordinary draw path rather than being
// routed through a player that would only ever return the same file.
Assert.False(UiMediaSequence.IsAnimated([Image(1u)]));
Assert.False(UiMediaSequence.IsAnimated([]));
Assert.False(UiMediaSequence.IsAnimated(null));
Assert.True(UiMediaSequence.IsAnimated([Image(1u), Pause(1f)]));
Assert.True(UiMediaSequence.IsAnimated([Image(1u), Image(2u)]));
}
[Fact]
public void AJumpLoopsAndKeepsRunningForever()
{
IReadOnlyList<UiMediaStep> looping =
[
Image(0xAAu), Pause(1f),
Image(0xBBu), Pause(1f),
Jump(0u),
];
Assert.Equal(0xAAu, UiMediaSequence.Sample(looping, 0.5f).File);
Assert.Equal(0xBBu, UiMediaSequence.Sample(looping, 1.5f).File);
Assert.Equal(0xAAu, UiMediaSequence.Sample(looping, 2.5f).File); // looped
Assert.Equal(0xBBu, UiMediaSequence.Sample(looping, 101.5f).File); // still going
}
[Fact]
public void AZeroTimeJumpCycleTerminatesInsteadOfHanging()
{
// A malformed sequence — a jump cycle with no pause in it — would spin
// forever inside one frame. It must return, not hang the client.
IReadOnlyList<UiMediaStep> pathological = [Image(0xAAu), Jump(0u)];
Assert.Equal(0xAAu, UiMediaSequence.Sample(pathological, 1f).File);
}
[Fact]
public void AnUncertainBranchFallsThroughRatherThanLooping()
{
// Falling through ends the sequence; treating it as "always" would
// animate forever. The conservative direction is to stop.
IReadOnlyList<UiMediaStep> maybe =
[
Image(0xAAu), Pause(1f), Jump(0u, probability: 0.5f), Image(0xBBu),
];
Assert.Equal(0xBBu, UiMediaSequence.Sample(maybe, 1.5f).File);
}
[Fact]
public void UnknownStepsAreSteppedOverWithoutBreakingJumpIndices()
{
// Sound, movie and message entries are kept as Other precisely so a
// jump's index still lands on the authored entry.
IReadOnlyList<UiMediaStep> withOther =
[
new(UiMediaStepKind.Other, 0u, 0, 0f, 0f, 0u, 0f),
Image(0xCCu),
Pause(1f),
Jump(1u),
];
Assert.Equal(0xCCu, UiMediaSequence.Sample(withOther, 0.5f).File);
Assert.Equal(0xCCu, UiMediaSequence.Sample(withOther, 5f).File);
}
}

View file

@ -49,49 +49,42 @@ Print(root, 0);
int mediaAt = Array.IndexOf(args, "--media");
if (mediaAt >= 0)
{
// The RAW media sequence per state, straight off the LayoutDesc. ElementInfo
// keeps only the first image, so an animation is invisible above that level.
// The RESOLVED media sequence per state — inheritance already applied by
// ImportInfos, which is what the raw LayoutDesc walk could not do.
uint wanted = mediaAt + 1 < args.Length
? Convert.ToUInt32(args[mediaAt + 1], 16)
: 0u;
// Layouts are not necessarily in Portal — go through the adapter, the same
// way LayoutImporter does.
var ld = adapter.Get<DatReaderWriter.DBObjs.LayoutDesc>(ids[0]);
if (ld is null)
{
Console.WriteLine($"layout 0x{ids[0]:X8} not found");
return 2;
}
foreach (var top in ld.Elements)
Walk(top.Value);
WalkMedia(root);
return 0;
void Walk(DatReaderWriter.Types.ElementDesc d)
void WalkMedia(ElementInfo e)
{
if (wanted == 0 || d.ElementId == wanted)
if (wanted == 0 || e.Id == wanted)
{
Console.WriteLine($"element 0x{d.ElementId:X8}");
if (d.States.Count == 0)
Console.WriteLine($"element 0x{e.Id:X8}");
foreach (var (stateId, st) in e.States.OrderBy(kv => kv.Key))
{
// Raw descriptors only carry what THIS element overrides; states
// and their media usually come from the base element, and
// LayoutDesc::InqFullDesc @0x0069A520 resolves that chain.
// Following it here would mean reimplementing LayoutImporter's
// Resolve, so say so rather than imply the element has none.
if (st.MediaSteps.Count == 0) continue;
Console.WriteLine(
$" (no states of its own — inherited from base 0x{d.BaseElement:X8};"
+ " raw media not resolved here)");
}
foreach (var st in d.States)
{
Console.WriteLine($" state {st.Key}: {st.Value.Media.Count} media");
foreach (var m in st.Value.Media)
Console.WriteLine($" {m.GetType().Name}");
$" state {stateId}{(st.Name.Length != 0 ? $" ({st.Name})" : "")}"
+ $": {st.MediaSteps.Count} steps");
for (int i = 0; i < st.MediaSteps.Count; i++)
{
UiMediaStep m = st.MediaSteps[i];
string detail = m.Kind switch
{
UiMediaStepKind.Image => $"file=0x{m.File:X8} draw={m.DrawMode}",
UiMediaStepKind.Pause => $"min={m.MinDuration} max={m.MaxDuration}",
UiMediaStepKind.Jump => $"to={m.JumpIndex} p={m.Probability}",
UiMediaStepKind.State => $"state={m.JumpIndex} p={m.Probability}",
_ => $"MediaType={(DatReaderWriter.Enums.MediaType)m.RawType}",
};
Console.WriteLine($" [{i,2}] {m.Kind,-6} {detail}");
}
}
}
foreach (var child in d.Children)
Walk(child.Value);
foreach (ElementInfo child in e.Children)
WalkMedia(child);
}
}