feat(D.2b): UiText (Type 12) -- generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)

Rename UiChatView -> UiText (the retail UIElement_Text class,
RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655).

Factory changes (DatWidgetFactory.cs):
- Remove the Type-12 skip (was: no-media -> null, with-media -> UiDatElement).
- Add Type 12 -> BuildText() -> UiText in the switch.
- BuildText extracts the element's Direct/Normal sprite as BackgroundSprite
  so any dat-media the element carried keeps rendering under the text.

UiText changes (renamed from UiChatView.cs):
- BackgroundColor default: (0,0,0,0.35) -> (0,0,0,0) (transparent).
  An unbound UiText draws nothing; the controller opts in to the translucent bg.
- New BackgroundSprite + SpriteResolve: optional dat state-sprite background
  drawn UNDER DrawFill+text (faithful UIElement_Text media support).

ChatWindowController.cs (Task 5 Step 8):
- Transcript property: UiChatView -> UiText.
- Bind() now uses layout.FindElement(TranscriptId) as UiText (factory-built)
  instead of manually constructing + AddChild-ing a new UiChatView.
- Sets BackgroundColor = (0,0,0,0.35) on the found widget (retail translucent bg).
- Removes the tInfo null-check from the early guard (transcript is factory-built;
  iInfo lookup kept for the input widget which is still manually constructed).
- BuildLines: UiChatView.Line -> UiText.Line throughout.

Vitals frozen: the Type-12 vitals number elements are meter children and are
never recursed by BuildWidget (the `if (w is not UiMeter)` gate), so they are
not built as widgets and keep rendering via UiMeter.Label. Vitals fixture
vitals_2100006C.json unchanged; LayoutConformanceTests + VitalsBindingTests green.

Tests:
- UiChatViewTests.cs -> UiTextTests.cs (class: UiTextTests, all UiChatView.* -> UiText.*)
- UiChatViewDatFontTests.cs -> UiTextDatFontTests.cs (same)
- DatWidgetFactoryTests: delete Type12_StylePrototype_ReturnsNull +
  DatWidgetFactory_Type12WithMedia_Renders; add Type12_Text_MakesUiText +
  DatWidgetFactory_Type12_AlwaysMakesUiText.
- LayoutImporterTests: BuildFromInfos_Type12Child_IsSkipped_Type3Present updated
  to assert IsType<UiText> (element is now in tree, transparent, not skipped).

Divergence register: AP-37 amended -- removed the "standalone Type-0 text
elements skipped / dat-text widget is Plan 2" clause (now shipped as UiText);
kept the meter-collapse clause and the vitals-numbers-via-UiMeter.Label clause.
AP-38/AP-39/AD-28 file references updated UiChatView.cs -> UiText.cs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 17:39:02 +02:00
parent 67e5b8cff2
commit cb082b59e4
10 changed files with 127 additions and 118 deletions

View file

@ -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 |

View file

@ -65,7 +65,7 @@ public sealed class ChatWindowController
public UiElement Root { get; private set; } = null!;
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
public UiChatView Transcript { get; private set; } = null!;
public UiText Transcript { get; private set; } = null!;
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
public UiChatInput Input { get; private set; } = null!;
@ -160,20 +160,20 @@ public sealed class ChatWindowController
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> 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
/// <summary>
/// Convert the ChatVM's detailed lines to the transcript's
/// <see cref="UiChatView.Line"/> record format, applying retail-faithful
/// <see cref="UiText.Line"/> record format, applying retail-faithful
/// per-<see cref="ChatKind"/> colors.
/// </summary>
private static IReadOnlyList<UiChatView.Line> BuildLines(
ChatVM vm, UiChatView view, UiDatFont? datFont, BitmapFont? debugFont)
private static IReadOnlyList<UiText.Line> BuildLines(
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
{
var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0) return Array.Empty<UiChatView.Line>();
if (detailed.Count == 0) return Array.Empty<UiText.Line>();
// 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<UiChatView.Line>(detailed.Count);
var result = new List<UiText.Line>(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;
}

View file

@ -10,11 +10,11 @@ namespace AcDream.App.UI.Layout;
/// <see cref="UiDatElement"/>.
///
/// <para>
/// Type 12 elements that carry NO own state media (pure style prototypes /
/// BaseElement stores) return null from <see cref="Create"/> 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 <see cref="UiText"/>. Elements that carry their own
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
/// prototype elements (no state media, no controller binding) draw nothing because
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
/// </para>
///
/// <para>
@ -45,23 +45,17 @@ public static class DatWidgetFactory
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
/// <param name="datFont">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.</param>
/// <returns>The widget, or <c>null</c> for a pure Type-12 style prototype with no own sprites (caller skips it).</returns>
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
public static UiElement? Create(ElementInfo info,
Func<uint, (uint, int, int)> 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 ─────────────────────────────────────────────────────────────────
/// <summary>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 <see cref="UiText.BackgroundColor"/> defaults to transparent.</summary>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> 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 };
}
}

View file

@ -42,7 +42,7 @@ public sealed class UiHost : System.IDisposable
/// <summary>The last wired keyboard. Exposed so widgets that need clipboard
/// access (<see cref="IKeyboard.ClipboardText"/>) or modifier-key state
/// (<see cref="IKeyboard.IsKeyPressed"/>) — e.g. <see cref="UiChatView"/>'s
/// (<see cref="IKeyboard.IsKeyPressed"/>) — e.g. <see cref="UiText"/>'s
/// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins.</summary>
public IKeyboard? Keyboard { get; private set; }

View file

@ -7,7 +7,7 @@ namespace AcDream.App.UI;
/// the scroll offset is an integer pixel value (<c>m_iScrollableY</c>) 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.
/// </summary>

View file

@ -7,8 +7,9 @@ using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// Scrollable chat transcript for the retail-look chat window. Renders the
/// lines from <see cref="LinesProvider"/> bottom-pinned (newest at the bottom,
/// Scrollable text view for retail UIElement_Text elements
/// (<c>RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655</c>).
/// Renders the lines from <see cref="LinesProvider"/> 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.
/// </para>
/// </summary>
public sealed class UiChatView : UiElement
public sealed class UiText : UiElement
{
/// <summary>One display line: pre-formatted text + its colour.</summary>
public readonly record struct Line(string Text, Vector4 Color);
@ -43,8 +44,18 @@ public sealed class UiChatView : UiElement
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
/// <summary>Backing fill behind the text (retail chat is a dark translucent box).</summary>
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f);
/// <summary>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. <c>ChatWindowController</c>).</summary>
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
/// <summary>Optional dat state-sprite background (the element's own media), drawn
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
public uint BackgroundSprite { get; set; }
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Highlight colour painted behind a selected character span.</summary>
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,

View file

@ -24,13 +24,13 @@ public class DatWidgetFactoryTests
Assert.IsType<UiDatElement>(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<UiText>(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) ──
/// <summary>
/// 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.
/// </summary>
[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<UiDatElement>(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<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
}
// ── Test 5c: Type 1 → UiButton ──────────────────────────────────────────

View file

@ -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 ──
/// <summary>
/// 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 <see cref="UiText"/> in the tree (transparent,
/// draws nothing until a controller binds its <c>LinesProvider</c>);
/// the Type-3 must also be present.
/// </summary>
[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<UiText>(tree.FindElement(0x20000001));
// Type-3 must also be present.
Assert.NotNull(tree.FindElement(0x20000002));
}

View file

@ -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]

View file

@ -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<UiChatView.Line> Lines(params string[] texts)
private static IReadOnlyList<UiText.Line> Lines(params string[] texts)
{
var list = new List<UiChatView.Line>(texts.Length);
var list = new List<UiText.Line>(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<UiChatView.Line>(),
new UiChatView.Pos(0, 0), new UiChatView.Pos(0, 0)));
Assert.Equal("", UiText.SelectedText(Array.Empty<UiText.Line>(),
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);
}
}