diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 86b2f37c..bedfc289 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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 |
---
diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index 27fef4c8..fb224e99 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -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.
///
+ ///
+ /// True once the authored flash has been started for the current batch of
+ /// unseen text, so it is not restarted every frame.
+ ///
+ 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);
}
/// Aims the chat entry at and focuses it.
diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs
index 1429cedc..01f23454 100644
--- a/src/AcDream.App/UI/Layout/LayoutImporter.cs
+++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs
@@ -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(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)
{
diff --git a/src/AcDream.App/UI/Layout/UiMediaSequence.cs b/src/AcDream.App/UI/Layout/UiMediaSequence.cs
new file mode 100644
index 00000000..2a38375b
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/UiMediaSequence.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// The clock authored media animations are sampled against.
+///
+///
+/// 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.
+///
+public static class UiMediaClock
+{
+ /// Seconds since the host started advancing this clock.
+ public static double Seconds { get; private set; }
+
+ public static void Advance(double deltaSeconds)
+ {
+ if (double.IsFinite(deltaSeconds) && deltaSeconds > 0d)
+ Seconds += deltaSeconds;
+ }
+
+ /// Test seam: rewind to a known point.
+ internal static void ResetForTest() => Seconds = 0d;
+}
+
+///
+/// Plays a retail UI state's media sequence.
+///
+///
+///
+/// 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 (0x1000048C) authors this,
+/// measured from the installed dats:
+///
+///
+/// [ 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
+///
+///
+/// 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.
+///
+///
+/// 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.
+///
+///
+public static class UiMediaSequence
+{
+ ///
+ /// Guards a malformed sequence whose jumps form a cycle with no elapsed
+ /// time — without it, such a sequence would spin forever inside one frame.
+ ///
+ private const int MaximumSteps = 512;
+
+ ///
+ /// Whether does anything over time. A state whose
+ /// media is a single image is NOT an animation and must keep the ordinary
+ /// still-frame path.
+ ///
+ public static bool IsAnimated(IReadOnlyList? 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;
+ }
+
+ ///
+ /// The frame showing at , and the state
+ /// the sequence hands off to if it has reached its end.
+ ///
+ ///
+ /// File is 0 when no image has been reached yet.
+ /// TransitionState is null until a terminal State step is due.
+ ///
+ public static (uint File, uint? TransitionState) Sample(
+ IReadOnlyList? 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);
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/UiPropertyBag.cs b/src/AcDream.App/UI/Layout/UiPropertyBag.cs
index 377a16d3..d81209c7 100644
--- a/src/AcDream.App/UI/Layout/UiPropertyBag.cs
+++ b/src/AcDream.App/UI/Layout/UiPropertyBag.cs
@@ -110,6 +110,45 @@ public sealed class UiPropertyBag
/// Primary render-surface media for a retail UI state.
public readonly record struct UiImageMedia(uint File, int DrawMode);
+/// What one entry of a state's media sequence does.
+public enum UiMediaStepKind
+{
+ /// Anything we do not act on (sound, movie, message, ...).
+ Other,
+
+ /// Show this image.
+ Image,
+
+ /// Hold the current image for a duration.
+ Pause,
+
+ /// Branch to another entry — what makes a sequence loop.
+ Jump,
+
+ /// Hand the element to another STATE when the sequence ends.
+ State,
+}
+
+///
+/// One entry of a retail state's media list.
+///
+///
+/// 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
+/// rather than dropped, so a jump's
+/// index still lands on the right entry.
+///
+public readonly record struct UiMediaStep(
+ UiMediaStepKind Kind,
+ uint File,
+ int DrawMode,
+ float MinDuration,
+ float MaxDuration,
+ uint JumpIndex,
+ float Probability,
+ int RawType = 0);
+
///
/// Dat-independent state descriptor. DirectState uses
/// so it cannot collide with UIStateId.Undef == 0.
@@ -123,6 +162,12 @@ public sealed class UiStateInfo
public bool PassToChildren;
public uint IncorporationFlags;
public UiImageMedia? Image;
+
+ ///
+ /// The state's media list in authored order, or empty when it has none.
+ /// remains the first drawable image — the still frame.
+ ///
+ public IReadOnlyList MediaSteps = Array.Empty();
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,
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index e666c753..864a9e96 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -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();
diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs
index 1e5297ce..0f77bfd7 100644
--- a/src/AcDream.App/UI/UiButton.cs
+++ b/src/AcDream.App/UI/UiButton.cs
@@ -332,7 +332,22 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches .
///
- 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;
+ ///
+ /// The frame this element's active state is showing right now, and the
+ /// state its sequence hands off to when it ends.
+ ///
+ ///
+ /// 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.
+ ///
+ 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/
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
index d1223134..93b53416 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
@@ -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(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(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()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/UiMediaSequenceTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiMediaSequenceTests.cs
new file mode 100644
index 00000000..c033a37e
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/UiMediaSequenceTests.cs
@@ -0,0 +1,139 @@
+using System.Collections.Generic;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Retail's state media is a small program, and this plays it.
+///
+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);
+
+ ///
+ /// 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.
+ ///
+ private static IReadOnlyList 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 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 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 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 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);
+ }
+}
diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs
index 38b8a2b0..ac6336dd 100644
--- a/tools/LayoutDump/Program.cs
+++ b/tools/LayoutDump/Program.cs
@@ -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(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);
}
}