diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 052cca56..23cb919a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -90,7 +90,7 @@ accepted-divergence entries (#96, #49, #50). | AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | | AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` | | AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius | -| AD-28 | Chat transcript (`UiChatView`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | +| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | --- @@ -134,9 +134,9 @@ accepted-divergence entries (#96, #49, #50). | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | -| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Now the default vitals path (the hand-authored markup vitals was retired) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` | -| 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-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed) and continue to render via `UiMeter.Label` bound by the controller (Task 8). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` | +| 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/UiText.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/UiText.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 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` | | AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 527e1fad..f4fdce87 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -65,7 +65,7 @@ public sealed class ChatWindowController public UiElement Root { get; private set; } = null!; /// Live chat transcript widget. Null until succeeds. - public UiChatView Transcript { get; private set; } = null!; + public UiText Transcript { get; private set; } = null!; /// Editable chat input widget. Null until succeeds. public UiChatInput Input { get; private set; } = null!; @@ -160,20 +160,20 @@ public sealed class ChatWindowController BitmapFont? debugFont, Func resolve) { - // The transcript + input nodes are Type-12 based and were skipped by the factory. - // Find them in the raw ElementInfo tree to read their rects. - var tInfo = FindInfo(rootInfo, TranscriptId); + // The transcript is now built as a UiText by the factory (Type 12 is no longer skipped). + // The input node (0x10000016) is still Type-12 based; find it in the raw ElementInfo + // tree to read its rect for the behavioral UiChatInput widget. var iInfo = FindInfo(rootInfo, InputId); // Their parent panels must exist as real widgets in the layout tree. var transcriptPanel = layout.FindElement(TranscriptPanelId); var inputBar = layout.FindElement(InputBarId); - if (tInfo is null || iInfo is null || transcriptPanel is null || inputBar is null) + if (iInfo is null || transcriptPanel is null || inputBar is null) { Console.WriteLine( $"[D.2b] ChatWindowController.Bind: missing required elements " + - $"(tInfo={tInfo is not null}, iInfo={iInfo is not null}, " + + $"(iInfo={iInfo is not null}, " + $"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " + $"chat window will not be interactive."); return null; @@ -204,20 +204,14 @@ public sealed class ChatWindowController transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9) // ── Transcript ─────────────────────────────────────────────────── - // Place the behavioral transcript widget inside the transcript panel at the - // dat-rect of the (skipped) Type-12 transcript element. - c.Transcript = new UiChatView - { - Left = tInfo.X, - Top = tInfo.Y, - Width = tInfo.Width, - Height = tInfo.Height, - Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom), - DatFont = datFont, - Font = debugFont, - LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont), - }; - transcriptPanel.AddChild(c.Transcript); + // The factory now builds the Type-12 transcript element (0x10000011) as a UiText. + // Find it in the widget tree and bind the live providers — no remove/add needed. + c.Transcript = layout.FindElement(TranscriptId) as UiText + ?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText"); + c.Transcript.DatFont = datFont; + c.Transcript.Font = debugFont; + c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript + c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); // ── Input ──────────────────────────────────────────────────────── // Place the behavioral input widget inside the input bar. @@ -373,14 +367,14 @@ public sealed class ChatWindowController /// /// Convert the ChatVM's detailed lines to the transcript's - /// record format, applying retail-faithful + /// record format, applying retail-faithful /// per- colors. /// - private static IReadOnlyList BuildLines( - ChatVM vm, UiChatView view, UiDatFont? datFont, BitmapFont? debugFont) + private static IReadOnlyList BuildLines( + ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont) { var detailed = vm.RecentLinesDetailed(); - if (detailed.Count == 0) return Array.Empty(); + if (detailed.Count == 0) return Array.Empty(); // Word-wrap each message to the transcript's current pixel width (ports retail // GlyphList::Recalculate @0x473800 — break at word boundaries when the line would @@ -391,12 +385,12 @@ public sealed class ChatWindowController : debugFont is { } bf ? s => bf.MeasureWidth(s) : s => s.Length * 7f; - var result = new List(detailed.Count); + var result = new List(detailed.Count); foreach (var d in detailed) { var color = RetailChatColor(d.Kind); foreach (var frag in WrapText(d.Text, maxW, measure)) - result.Add(new UiChatView.Line(frag, color)); + result.Add(new UiText.Line(frag, color)); } return result; } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 556fc3ee..4c90f37e 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -10,11 +10,11 @@ namespace AcDream.App.UI.Layout; /// . /// /// -/// Type 12 elements that carry NO own state media (pure style prototypes / -/// BaseElement stores) return null from and are skipped. -/// Type 12 elements that DO carry own sprites (e.g. a chat element whose Type-0 -/// derived form inherited Type 12 from its base prototype) are rendered normally. -/// See docs/research/2026-06-15-layoutdesc-format.md Correction 8. +/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12 +/// element is now built as a . Elements that carry their own +/// dat sprite media keep it as the . Pure +/// prototype elements (no state media, no controller binding) draw nothing because +/// defaults to transparent. /// /// /// @@ -45,23 +45,17 @@ public static class DatWidgetFactory /// Returns (0,0,0) when the texture is not yet uploaded. /// Retail UI font for the meter's "cur/max" number overlay. /// May be null pre-load — the meter falls back to the debug bitmap font. - /// The widget, or null for a pure Type-12 style prototype with no own sprites (caller skips it). + /// The widget for this element. Never null — every type produces a widget. public static UiElement? Create(ElementInfo info, Func resolve, UiDatFont? datFont) { - // Type 12 = style prototype / BaseElement store referenced by BaseLayoutId. - // PURE prototypes (no own state media) are property bags — never rendered; skip them. - // A Type-12 element that carries its own state media (e.g. a chat Send button whose - // Type-0 derived element inherited Type 12 from its base prototype) has sprites to - // show and must render. See format doc §8 and the G1 task note. - if (info.Type == 12 && info.StateMedia.Count == 0) return null; - UiElement e = info.Type switch { 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) + 12 => BuildText(info, resolve), // UIElement_Text (reg :115655) _ => new UiDatElement(info, resolve), // generic fallback for all other types }; @@ -178,4 +172,20 @@ public static class DatWidgetFactory return (left, tile, right); } + + // ── Text ───────────────────────────────────────────────────────────────── + + /// Type-12 UIElement_Text: a scrollable colored-line text view. The element's + /// own Direct/Normal media (if any) becomes the background sprite, drawn under the text — + /// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines + /// are bound later by the controller (LinesProvider). An unbound UiText draws nothing + /// because defaults to transparent. + private static UiText BuildText(ElementInfo info, Func resolve) + { + uint bg = info.StateMedia.TryGetValue( + !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName + : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) + ? m.File : 0u; + return new UiText { BackgroundSprite = bg, SpriteResolve = resolve }; + } } diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index a372f891..718d5cbd 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -42,7 +42,7 @@ public sealed class UiHost : System.IDisposable /// The last wired keyboard. Exposed so widgets that need clipboard /// access () or modifier-key state - /// () — e.g. 's + /// () — e.g. 's /// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins. public IKeyboard? Keyboard { get; private set; } diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs index 2167b387..f9e78a12 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 (UiScrollbar). +/// shared by the transcript (UiText) and the scrollbar (UiScrollbar). /// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0, /// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0. /// diff --git a/src/AcDream.App/UI/UiChatView.cs b/src/AcDream.App/UI/UiText.cs similarity index 92% rename from src/AcDream.App/UI/UiChatView.cs rename to src/AcDream.App/UI/UiText.cs index e49e58a1..439350db 100644 --- a/src/AcDream.App/UI/UiChatView.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -7,8 +7,9 @@ using AcDream.App.Rendering; namespace AcDream.App.UI; /// -/// Scrollable chat transcript for the retail-look chat window. Renders the -/// lines from bottom-pinned (newest at the bottom, +/// Scrollable text view for retail UIElement_Text elements +/// (RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655). +/// Renders the lines from bottom-pinned (newest at the bottom, /// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps /// text inside the window. /// @@ -19,7 +20,7 @@ namespace AcDream.App.UI; /// selected span to the clipboard. Ctrl+A selects everything. /// /// -public sealed class UiChatView : UiElement +public sealed class UiText : UiElement { /// One display line: pre-formatted text + its colour. public readonly record struct Line(string Text, Vector4 Color); @@ -43,8 +44,18 @@ public sealed class UiChatView : UiElement /// the host from . public Silk.NET.Input.IKeyboard? Keyboard { get; set; } - /// Backing fill behind the text (retail chat is a dark translucent box). - public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + /// Backing fill behind the text. Defaults to transparent so an unbound + /// UiText (no controller) draws nothing. Set to the retail translucent value by + /// the controller (e.g. ChatWindowController). + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); + + /// Optional dat state-sprite background (the element's own media), drawn + /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. + public uint BackgroundSprite { get; set; } + + /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). + /// Required when is non-zero. + public Func? SpriteResolve { get; set; } /// Highlight colour painted behind a selected character span. public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); @@ -73,7 +84,7 @@ public sealed class UiChatView : UiElement private Pos? _selCaret; // where the drag currently is private bool _selecting; - public UiChatView() + public UiText() { AcceptsFocus = true; IsEditControl = true; // absorb keys (Ctrl+C) while focused @@ -93,6 +104,14 @@ public sealed class UiChatView : UiElement protected override void OnDraw(UiRenderContext ctx) { + // Optional dat state-sprite background drawn UNDER everything else. + if (BackgroundSprite != 0 && SpriteResolve is { } sr) + { + var (tex, tw, th) = sr(BackgroundSprite); + if (tex != 0 && tw != 0 && th != 0) + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } + // Background must draw UNDER the transcript text. DrawStringDat emits into the // sprite bucket which flushes BEFORE rects, so a DrawRect background would wash // over the text. DrawFill routes the background through the sprite bucket too, diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 4f546920..2dd4cd1c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -24,13 +24,13 @@ public class DatWidgetFactoryTests Assert.IsType(e); } - // ── Test 3: Type 12 → null (style prototype, never rendered) ───────────── + // ── Test 3: Type 12 → UiText (behavioral text widget) ──────────────────── [Fact] - public void Type12_StylePrototype_ReturnsNull() + public void Type12_Text_MakesUiText() { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null); - Assert.Null(e); + var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null); + Assert.IsType(e); } // ── Test 4: Rect + anchors set from ElementInfo ─────────────────────────── @@ -71,30 +71,15 @@ public class DatWidgetFactoryTests Assert.Equal(7, e!.ZOrder); } - // ── Test G1a: Type 12 with own sprites renders; without sprites is skipped ── + // ── Test G1a: Type 12 always produces UiText (with or without own sprites) ── - /// - /// Task G1 change 1: only PURE Type-12 prototypes (no state media) are skipped. - /// A Type-12 element that carries its own state media must return a non-null widget. - /// [Fact] - public void DatWidgetFactory_Type12WithMedia_Renders() + public void DatWidgetFactory_Type12_AlwaysMakesUiText() { - // Type 12 with a "Normal" state sprite — must render (NOT skipped). - var withMedia = new ElementInfo - { - Type = 12, - Width = 32, - Height = 16, - StateMedia = { ["Normal"] = (0x00001234u, 1) }, - }; - var e = DatWidgetFactory.Create(withMedia, NoTex, null); - Assert.NotNull(e); - Assert.IsType(e); - - // Type 12 with NO state media — must still be skipped (pure prototype). - var noMedia = new ElementInfo { Type = 12 }; - Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null)); + var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16, + StateMedia = { ["Normal"] = (0x00001234u, 1) } }; + Assert.IsType(DatWidgetFactory.Create(withMedia, NoTex, null)); + Assert.IsType(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null)); } // ── Test 5c: Type 1 → UiButton ────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs index 2292aab8..a5f19e79 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs @@ -32,12 +32,13 @@ public class LayoutImporterTests Assert.Equal(150f, found.Width); } - // ── Test 2: Type-12 child is skipped; Type-3 sibling is present ────────── + // ── Test 2: Type-12 child builds a UiText; Type-3 sibling is also present ── /// - /// A root with two children: one Type-12 style prototype and one Type-3 container. - /// The Type-12 must be absent from the tree (FindElement returns null); - /// the Type-3 must be present. + /// A root with two children: one Type-12 UIElement_Text and one Type-3 container. + /// The Type-12 must appear as a in the tree (transparent, + /// draws nothing until a controller binds its LinesProvider); + /// the Type-3 must also be present. /// [Fact] public void BuildFromInfos_Type12Child_IsSkipped_Type3Present() @@ -48,9 +49,9 @@ public class LayoutImporterTests var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null); - // Type-12 must be absent. - Assert.Null(tree.FindElement(0x20000001)); - // Type-3 must be present. + // Type-12 is now a UiText (transparent, no lines) — present in the tree. + Assert.IsType(tree.FindElement(0x20000001)); + // Type-3 must also be present. Assert.NotNull(tree.FindElement(0x20000002)); } diff --git a/tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs similarity index 74% rename from tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs rename to tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs index c00c9544..11e6d1eb 100644 --- a/tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs @@ -4,7 +4,7 @@ using Xunit; namespace AcDream.App.Tests.UI; -public class UiChatViewDatFontTests +public class UiTextDatFontTests { // Synthetic per-char advance: each glyph 10px (Before=2,Width=6,After=2). private static FontCharDesc Glyph(char c) => new() @@ -17,9 +17,9 @@ public class UiChatViewDatFontTests public void CharIndexAt_UsesDatGlyphAdvance() { float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); - Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f)); - Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f)); - Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f)); + Assert.Equal(0, UiText.CharIndexAt("abc", Adv, 4f)); + Assert.Equal(1, UiText.CharIndexAt("abc", Adv, 12f)); + Assert.Equal(3, UiText.CharIndexAt("abc", Adv, 100f)); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs similarity index 51% rename from tests/AcDream.App.Tests/UI/UiChatViewTests.cs rename to tests/AcDream.App.Tests/UI/UiTextTests.cs index 7a02b183..691dc213 100644 --- a/tests/AcDream.App.Tests/UI/UiChatViewTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -5,28 +5,28 @@ using AcDream.App.UI; namespace AcDream.App.Tests.UI; -public class UiChatViewTests +public class UiTextTests { [Fact] public void ClampScroll_PinsToZero_WhenContentFitsView() { // 5 lines of content in a taller view → nothing to scroll, pinned at 0. - Assert.Equal(0f, UiChatView.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f)); - Assert.Equal(0f, UiChatView.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f)); + Assert.Equal(0f, UiText.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f)); + Assert.Equal(0f, UiText.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f)); } [Fact] public void ClampScroll_CapsAtContentMinusView_WhenOverflowing() { // Content 500, view 200 → max scrollback is 300px (oldest line at top). - Assert.Equal(300f, UiChatView.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f)); - Assert.Equal(120f, UiChatView.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f)); + Assert.Equal(300f, UiText.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f)); + Assert.Equal(120f, UiText.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f)); } [Fact] public void ClampScroll_NeverNegative() { - Assert.Equal(0f, UiChatView.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f)); + Assert.Equal(0f, UiText.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f)); } // ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ── @@ -36,39 +36,39 @@ public class UiChatViewTests [Fact] public void CharIndexAt_ZeroOrNegative_IsColumnZero() { - Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 0f)); - Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, -5f)); + Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 0f)); + Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, -5f)); } [Fact] public void CharIndexAt_SnapsToGlyphMidpoint() { // glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ... - Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0 - Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0 - Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1 - Assert.Equal(2, UiChatView.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1 + Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0 + Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0 + Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1 + Assert.Equal(2, UiText.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1 } [Fact] public void CharIndexAt_PastEnd_IsLength() { - Assert.Equal(5, UiChatView.CharIndexAt("hello", Mono10, 1000f)); + Assert.Equal(5, UiText.CharIndexAt("hello", Mono10, 1000f)); } [Fact] public void CharIndexAt_EmptyString_IsZero() { - Assert.Equal(0, UiChatView.CharIndexAt("", Mono10, 50f)); + Assert.Equal(0, UiText.CharIndexAt("", Mono10, 50f)); } // ── SelectedText assembly ──────────────────────────────────────────── - private static IReadOnlyList Lines(params string[] texts) + private static IReadOnlyList Lines(params string[] texts) { - var list = new List(texts.Length); + var list = new List(texts.Length); foreach (var t in texts) - list.Add(new UiChatView.Line(t, new Vector4(1, 1, 1, 1))); + list.Add(new UiText.Line(t, new Vector4(1, 1, 1, 1))); return list; } @@ -76,7 +76,7 @@ public class UiChatViewTests public void SelectedText_SingleLine_Substring() { var lines = Lines("hello world"); - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(0, 11)); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(0, 11)); Assert.Equal("world", s); } @@ -85,7 +85,7 @@ public class UiChatViewTests { var lines = Lines("hello world"); // caret BEFORE anchor — Order() must normalise. - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 11), new UiChatView.Pos(0, 6)); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 11), new UiText.Pos(0, 6)); Assert.Equal("world", s); } @@ -93,7 +93,7 @@ public class UiChatViewTests public void SelectedText_SamePosition_IsEmpty() { var lines = Lines("hello"); - Assert.Equal("", UiChatView.SelectedText(lines, new UiChatView.Pos(0, 3), new UiChatView.Pos(0, 3))); + Assert.Equal("", UiText.SelectedText(lines, new UiText.Pos(0, 3), new UiText.Pos(0, 3))); } [Fact] @@ -101,7 +101,7 @@ public class UiChatViewTests { var lines = Lines("first line", "second line", "third line"); // from col 6 of line 0 ("line") through col 5 of line 2 ("third") - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 6), new UiChatView.Pos(2, 5)); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(2, 5)); Assert.Equal("line\nsecond line\nthird", s); } @@ -109,7 +109,7 @@ public class UiChatViewTests public void SelectedText_MultiLine_TwoLines_NoMiddle() { var lines = Lines("alpha", "bravo"); - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(0, 2), new UiChatView.Pos(1, 3)); + var s = UiText.SelectedText(lines, new UiText.Pos(0, 2), new UiText.Pos(1, 3)); Assert.Equal("pha\nbra", s); } @@ -118,26 +118,26 @@ public class UiChatViewTests { var lines = Lines("alpha", "bravo"); // end before start → Order() swaps them. - var s = UiChatView.SelectedText(lines, new UiChatView.Pos(1, 3), new UiChatView.Pos(0, 2)); + var s = UiText.SelectedText(lines, new UiText.Pos(1, 3), new UiText.Pos(0, 2)); Assert.Equal("pha\nbra", s); } [Fact] public void SelectedText_EmptyLineList_IsEmpty() { - Assert.Equal("", UiChatView.SelectedText(Array.Empty(), - new UiChatView.Pos(0, 0), new UiChatView.Pos(0, 0))); + Assert.Equal("", UiText.SelectedText(Array.Empty(), + new UiText.Pos(0, 0), new UiText.Pos(0, 0))); } [Fact] public void Order_SortsByLineThenColumn() { - var (s1, e1) = UiChatView.Order(new UiChatView.Pos(2, 1), new UiChatView.Pos(0, 5)); - Assert.Equal(new UiChatView.Pos(0, 5), s1); - Assert.Equal(new UiChatView.Pos(2, 1), e1); + var (s1, e1) = UiText.Order(new UiText.Pos(2, 1), new UiText.Pos(0, 5)); + Assert.Equal(new UiText.Pos(0, 5), s1); + Assert.Equal(new UiText.Pos(2, 1), e1); - var (s2, e2) = UiChatView.Order(new UiChatView.Pos(1, 8), new UiChatView.Pos(1, 2)); - Assert.Equal(new UiChatView.Pos(1, 2), s2); - Assert.Equal(new UiChatView.Pos(1, 8), e2); + var (s2, e2) = UiText.Order(new UiText.Pos(1, 8), new UiText.Pos(1, 2)); + Assert.Equal(new UiText.Pos(1, 2), s2); + Assert.Equal(new UiText.Pos(1, 8), e2); } }