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>
139 lines
4.9 KiB
C#
139 lines
4.9 KiB
C#
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);
|
|
}
|
|
}
|