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