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