diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a96511a6..308c03bb 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -138,7 +138,7 @@ accepted-divergence entries (#96, #49, #50). | AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiChatView.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | | AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | | AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | -| AP-41 | Scrollbar thumb drawn as a single stretched sprite (`0x06004C63`, the 3-slice middle tile) instead of retail's 3-slice: top cap `0x06004C60` + tiled middle `0x06004C63` + bottom cap `0x06004C66` | `src/AcDream.App/UI/UiChatScrollbar.cs:37` | The middle tile stretches acceptably at chat-panel dimensions; the 3-slice port is a Task-H upgrade acknowledged inline in the `ThumbSprite` property comment | The thumb's top and bottom edges lack the retail end-cap sprites — slightly wrong visual shape at small thumb sizes (thumb too-short for the middle tile to cleanly scale) | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | +| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | --- diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 5b6199db..87e1f2de 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -20,9 +20,9 @@ namespace AcDream.App.UI.Layout; /// tree (which contains everything) and adds the behavioral /// widgets as children of their parent container widgets (transcript panel /// 0x10000010 / input bar 0x10000013) which ARE created as -/// nodes. The scrollbar track (0x10000012) and -/// channel menu (0x10000014) are created by the factory and are replaced -/// with their behavioral counterparts here. +/// nodes. The scrollbar track (0x10000012) is built +/// directly as a by the factory (Type 11) and is bound in place +/// here. The channel menu (0x10000014) is still replaced with its behavioral counterpart. /// /// public sealed class ChatWindowController @@ -71,7 +71,7 @@ public sealed class ChatWindowController public UiChatInput Input { get; private set; } = null!; /// Scrollbar widget, driven by 's scroll model. - public UiChatScrollbar Scrollbar { get; private set; } = null!; + public UiScrollbar Scrollbar { get; private set; } = null!; /// Channel-selector menu widget. public UiChannelMenu Menu { get; private set; } = null!; @@ -110,7 +110,7 @@ public sealed class ChatWindowController /// Fallback debug bitmap font (used when /// is null). /// Dat RenderSurface id → (GL tex handle, px width, px height). - /// Forwarded to and . + /// Forwarded to and . public static ChatWindowController? Bind( ElementInfo rootInfo, ImportedLayout layout, @@ -196,33 +196,24 @@ public sealed class ChatWindowController inputBar.AddChild(c.Input); c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); - // ── Scrollbar — replace the imported track placeholder ──────────── - // The factory created a UiDatElement for the track. Remove it and place a - // behavioral UiChatScrollbar at the same position, driving the transcript's scroll. + // ── Scrollbar — bind the factory-built Type-11 track element ──────── + // The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar + // directly. Find it, bind it in place — no remove/add needed. var track = layout.FindElement(TrackId); - if (track?.Parent is { } trackParent) + if (track is UiScrollbar bar) { - c.Scrollbar = new UiChatScrollbar - { - // Pull the bar up to the panel top so the top arrow meets the window - // border (and lines up with the max/min button at root y=0); the dat - // track sits 6px down, which left a gap after the resize-bar reclaim. - Left = track.Left, - Top = 0f, - Width = track.Width, - Height = track.Height + track.Top, - Anchors = track.Anchors, - Model = c.Transcript.Scroll, - SpriteResolve = resolve, - TrackSprite = TrackSprite, - ThumbSprite = ThumbSprite, - ThumbTopSprite = ThumbTopSprite, - ThumbBotSprite = ThumbBotSprite, - UpSprite = UpSprite, - DownSprite = DownSprite, - }; - trackParent.RemoveChild(track); - trackParent.AddChild(c.Scrollbar); + float oldTop = bar.Top; + bar.Top = 0f; // pull up to the panel top (resize-bar reclaim) + bar.Height = bar.Height + oldTop; + bar.Model = c.Transcript.Scroll; + bar.SpriteResolve = resolve; + bar.TrackSprite = TrackSprite; + bar.ThumbSprite = ThumbSprite; + bar.ThumbTopSprite = ThumbTopSprite; + bar.ThumbBotSprite = ThumbBotSprite; + bar.UpSprite = UpSprite; + bar.DownSprite = DownSprite; + c.Scrollbar = bar; } // ── Channel menu — replace the imported menu placeholder ────────── diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index d4df6589..ee4d3da4 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -57,8 +57,9 @@ public static class DatWidgetFactory UiElement e = info.Type switch { - 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter - _ => new UiDatElement(info, resolve), // generic fallback for all other types + 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter + 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) + _ => new UiDatElement(info, resolve), // generic fallback for all other types }; // Propagate position + size (pixel-exact from the dat). diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiChatView.cs index cff1ea6c..e49e58a1 100644 --- a/src/AcDream.App/UI/UiChatView.cs +++ b/src/AcDream.App/UI/UiChatView.cs @@ -52,7 +52,7 @@ public sealed class UiChatView : UiElement /// Inner text inset from the view edges, px. public float Padding { get; set; } = 4f; - /// The scroll model — also read by the linked UiChatScrollbar. + /// The scroll model — also read by the linked UiScrollbar. public UiScrollable Scroll { get; } = new(); /// True while the view is pinned to the newest line (auto-scrolls as content grows). diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs index d30e2a0a..2167b387 100644 --- a/src/AcDream.App/UI/UiScrollable.cs +++ b/src/AcDream.App/UI/UiScrollable.cs @@ -7,7 +7,7 @@ namespace AcDream.App.UI; /// the scroll offset is an integer pixel value (m_iScrollableY) clamped to /// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position /// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and -/// shared by the transcript (UiChatView) and the scrollbar (UiChatScrollbar). +/// shared by the transcript (UiChatView) and the scrollbar (UiScrollbar). /// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0, /// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0. /// diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs similarity index 94% rename from src/AcDream.App/UI/UiChatScrollbar.cs rename to src/AcDream.App/UI/UiScrollbar.cs index debea724..99e4dcdc 100644 --- a/src/AcDream.App/UI/UiChatScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -4,11 +4,9 @@ using System.Numerics; namespace AcDream.App.UI; /// -/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the -/// content/view ratio, and up/down step buttons. Drives a linked -/// . Ports retail UIElement_Scrollbar::UpdateLayout -/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from -/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line). +/// Generic scrollbar. Ports retail UIElement_Scrollbar +/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137); +/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line. /// /// /// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68), @@ -22,7 +20,7 @@ namespace AcDream.App.UI; /// rendered scrollbar's height; the widget responds to those regions directly via hit /// comparison in OnEvent without requiring separate child elements. /// -public sealed class UiChatScrollbar : UiElement +public sealed class UiScrollbar : UiElement { /// The scroll model this bar reflects + drives (shared with the transcript). public UiScrollable? Model { get; set; } @@ -61,7 +59,7 @@ public sealed class UiChatScrollbar : UiElement private bool _draggingThumb; private float _dragOffsetY; - public UiChatScrollbar() { CapturesPointerDrag = true; } + public UiScrollbar() { CapturesPointerDrag = true; } /// /// Computes the thumb rectangle (local y origin and height) within the track area diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 31b449bd..cd543635 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -97,6 +97,15 @@ public class DatWidgetFactoryTests Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null)); } + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── + + [Fact] + public void Type11_Scrollbar_MakesUiScrollbar() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null); + Assert.IsType(e); + } + // ── Test 6: Meter slice extraction (the important one) ─────────────────── /// diff --git a/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs similarity index 79% rename from tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs rename to tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index 3f4ddbba..c2239732 100644 --- a/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -4,9 +4,9 @@ using Xunit; namespace AcDream.App.Tests.UI; /// -/// Pure unit tests for — no GL dependency. +/// Pure unit tests for — no GL dependency. /// -public class UiChatScrollbarTests +public class UiScrollbarTests { // Model: content=400, view=100, trackLen=200. // ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50. @@ -17,7 +17,7 @@ public class UiChatScrollbarTests { var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; // PositionRatio = 0 (start). - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); Assert.Equal(50f, h, 3f); Assert.Equal(0f, y, 3f); } @@ -28,7 +28,7 @@ public class UiChatScrollbarTests var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; m.ScrollToEnd(); // PositionRatio = 1. float trackTop = 16f, trackLen = 200f; - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop, trackLen); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop, trackLen); Assert.Equal(50f, h, 3f); // y = trackTop + travel * 1 = 16 + 150 = 166. Assert.Equal(166f, y, 3f); @@ -41,7 +41,7 @@ public class UiChatScrollbarTests // thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150. var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; m.ScrollToEnd(); - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f); Assert.Equal(50f, h, 3f); Assert.Equal(166f, y, 3f); // 16 + 150 } @@ -54,7 +54,7 @@ public class UiChatScrollbarTests m.SetScrollY(150); Assert.Equal(0.5f, m.PositionRatio, 3); - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); Assert.Equal(50f, h, 3f); // y = 0 + 150 * 0.5 = 75. Assert.Equal(75f, y, 3f); @@ -65,7 +65,7 @@ public class UiChatScrollbarTests { // content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8. var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 }; - var (_, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + var (_, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); Assert.Equal(8f, h, 3f); } @@ -74,7 +74,7 @@ public class UiChatScrollbarTests { // content <= view → ThumbRatio = 1 → thumbH = trackLen. var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; - var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f); + var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f); Assert.Equal(100f, h, 3f); Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop }