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:
parent
67e5b8cff2
commit
cb082b59e4
10 changed files with 127 additions and 118 deletions
|
|
@ -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-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-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-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-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-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-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-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/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-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/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-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-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-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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ public sealed class ChatWindowController
|
||||||
public UiElement Root { get; private set; } = null!;
|
public UiElement Root { get; private set; } = null!;
|
||||||
|
|
||||||
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
|
/// <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>
|
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
|
||||||
public UiChatInput Input { get; private set; } = null!;
|
public UiChatInput Input { get; private set; } = null!;
|
||||||
|
|
@ -160,20 +160,20 @@ public sealed class ChatWindowController
|
||||||
BitmapFont? debugFont,
|
BitmapFont? debugFont,
|
||||||
Func<uint, (uint tex, int w, int h)> resolve)
|
Func<uint, (uint tex, int w, int h)> resolve)
|
||||||
{
|
{
|
||||||
// The transcript + input nodes are Type-12 based and were skipped by the factory.
|
// The transcript is now built as a UiText by the factory (Type 12 is no longer skipped).
|
||||||
// Find them in the raw ElementInfo tree to read their rects.
|
// The input node (0x10000016) is still Type-12 based; find it in the raw ElementInfo
|
||||||
var tInfo = FindInfo(rootInfo, TranscriptId);
|
// tree to read its rect for the behavioral UiChatInput widget.
|
||||||
var iInfo = FindInfo(rootInfo, InputId);
|
var iInfo = FindInfo(rootInfo, InputId);
|
||||||
|
|
||||||
// Their parent panels must exist as real widgets in the layout tree.
|
// Their parent panels must exist as real widgets in the layout tree.
|
||||||
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
||||||
var inputBar = layout.FindElement(InputBarId);
|
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(
|
Console.WriteLine(
|
||||||
$"[D.2b] ChatWindowController.Bind: missing required elements " +
|
$"[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}) — " +
|
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
|
||||||
$"chat window will not be interactive.");
|
$"chat window will not be interactive.");
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -204,20 +204,14 @@ public sealed class ChatWindowController
|
||||||
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
|
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
|
||||||
|
|
||||||
// ── Transcript ───────────────────────────────────────────────────
|
// ── Transcript ───────────────────────────────────────────────────
|
||||||
// Place the behavioral transcript widget inside the transcript panel at the
|
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
|
||||||
// dat-rect of the (skipped) Type-12 transcript element.
|
// Find it in the widget tree and bind the live providers — no remove/add needed.
|
||||||
c.Transcript = new UiChatView
|
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
||||||
{
|
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
||||||
Left = tInfo.X,
|
c.Transcript.DatFont = datFont;
|
||||||
Top = tInfo.Y,
|
c.Transcript.Font = debugFont;
|
||||||
Width = tInfo.Width,
|
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||||
Height = tInfo.Height,
|
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||||
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);
|
|
||||||
|
|
||||||
// ── Input ────────────────────────────────────────────────────────
|
// ── Input ────────────────────────────────────────────────────────
|
||||||
// Place the behavioral input widget inside the input bar.
|
// Place the behavioral input widget inside the input bar.
|
||||||
|
|
@ -373,14 +367,14 @@ public sealed class ChatWindowController
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Convert the ChatVM's detailed lines to the transcript's
|
/// 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.
|
/// per-<see cref="ChatKind"/> colors.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IReadOnlyList<UiChatView.Line> BuildLines(
|
private static IReadOnlyList<UiText.Line> BuildLines(
|
||||||
ChatVM vm, UiChatView view, UiDatFont? datFont, BitmapFont? debugFont)
|
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
|
||||||
{
|
{
|
||||||
var detailed = vm.RecentLinesDetailed();
|
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
|
// Word-wrap each message to the transcript's current pixel width (ports retail
|
||||||
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
|
// 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)
|
: debugFont is { } bf ? s => bf.MeasureWidth(s)
|
||||||
: s => s.Length * 7f;
|
: 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)
|
foreach (var d in detailed)
|
||||||
{
|
{
|
||||||
var color = RetailChatColor(d.Kind);
|
var color = RetailChatColor(d.Kind);
|
||||||
foreach (var frag in WrapText(d.Text, maxW, measure))
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// <see cref="UiDatElement"/>.
|
/// <see cref="UiDatElement"/>.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Type 12 elements that carry NO own state media (pure style prototypes /
|
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12
|
||||||
/// BaseElement stores) return null from <see cref="Create"/> and are skipped.
|
/// element is now built as a <see cref="UiText"/>. Elements that carry their own
|
||||||
/// Type 12 elements that DO carry own sprites (e.g. a chat element whose Type-0
|
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
|
||||||
/// derived form inherited Type 12 from its base prototype) are rendered normally.
|
/// prototype elements (no state media, no controller binding) draw nothing because
|
||||||
/// See docs/research/2026-06-15-layoutdesc-format.md Correction 8.
|
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -45,23 +45,17 @@ public static class DatWidgetFactory
|
||||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
/// 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.
|
/// <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>
|
/// 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,
|
public static UiElement? Create(ElementInfo info,
|
||||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
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
|
UiElement e = info.Type switch
|
||||||
{
|
{
|
||||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||||
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
|
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
|
||||||
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
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
|
_ => new UiDatElement(info, resolve), // generic fallback for all other types
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -178,4 +172,20 @@ public static class DatWidgetFactory
|
||||||
|
|
||||||
return (left, tile, right);
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ public sealed class UiHost : System.IDisposable
|
||||||
|
|
||||||
/// <summary>The last wired keyboard. Exposed so widgets that need clipboard
|
/// <summary>The last wired keyboard. Exposed so widgets that need clipboard
|
||||||
/// access (<see cref="IKeyboard.ClipboardText"/>) or modifier-key state
|
/// 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>
|
/// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins.</summary>
|
||||||
public IKeyboard? Keyboard { get; private set; }
|
public IKeyboard? Keyboard { get; private set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ namespace AcDream.App.UI;
|
||||||
/// the scroll offset is an integer pixel value (<c>m_iScrollableY</c>) clamped to
|
/// 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
|
/// [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
|
/// 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,
|
/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0,
|
||||||
/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0.
|
/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ using AcDream.App.Rendering;
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scrollable chat transcript for the retail-look chat window. Renders the
|
/// Scrollable text view for retail UIElement_Text elements
|
||||||
/// lines from <see cref="LinesProvider"/> bottom-pinned (newest at the bottom,
|
/// (<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
|
/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps
|
||||||
/// text inside the window.
|
/// text inside the window.
|
||||||
///
|
///
|
||||||
|
|
@ -19,7 +20,7 @@ namespace AcDream.App.UI;
|
||||||
/// selected span to the clipboard. Ctrl+A selects everything.
|
/// selected span to the clipboard. Ctrl+A selects everything.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UiChatView : UiElement
|
public sealed class UiText : UiElement
|
||||||
{
|
{
|
||||||
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
/// <summary>One display line: pre-formatted text + its colour.</summary>
|
||||||
public readonly record struct Line(string Text, Vector4 Color);
|
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>
|
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||||
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
|
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
|
||||||
|
|
||||||
/// <summary>Backing fill behind the text (retail chat is a dark translucent box).</summary>
|
/// <summary>Backing fill behind the text. Defaults to transparent so an unbound
|
||||||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f);
|
/// 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>
|
/// <summary>Highlight colour painted behind a selected character span.</summary>
|
||||||
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
|
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 Pos? _selCaret; // where the drag currently is
|
||||||
private bool _selecting;
|
private bool _selecting;
|
||||||
|
|
||||||
public UiChatView()
|
public UiText()
|
||||||
{
|
{
|
||||||
AcceptsFocus = true;
|
AcceptsFocus = true;
|
||||||
IsEditControl = true; // absorb keys (Ctrl+C) while focused
|
IsEditControl = true; // absorb keys (Ctrl+C) while focused
|
||||||
|
|
@ -93,6 +104,14 @@ public sealed class UiChatView : UiElement
|
||||||
|
|
||||||
protected override void OnDraw(UiRenderContext ctx)
|
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
|
// Background must draw UNDER the transcript text. DrawStringDat emits into the
|
||||||
// sprite bucket which flushes BEFORE rects, so a DrawRect background would wash
|
// sprite bucket which flushes BEFORE rects, so a DrawRect background would wash
|
||||||
// over the text. DrawFill routes the background through the sprite bucket too,
|
// over the text. DrawFill routes the background through the sprite bucket too,
|
||||||
|
|
@ -24,13 +24,13 @@ public class DatWidgetFactoryTests
|
||||||
Assert.IsType<UiDatElement>(e);
|
Assert.IsType<UiDatElement>(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Test 3: Type 12 → null (style prototype, never rendered) ─────────────
|
// ── Test 3: Type 12 → UiText (behavioral text widget) ────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Type12_StylePrototype_ReturnsNull()
|
public void Type12_Text_MakesUiText()
|
||||||
{
|
{
|
||||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null);
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
|
||||||
Assert.Null(e);
|
Assert.IsType<UiText>(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
|
// ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
|
||||||
|
|
@ -71,30 +71,15 @@ public class DatWidgetFactoryTests
|
||||||
Assert.Equal(7, e!.ZOrder);
|
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]
|
[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,
|
||||||
var withMedia = new ElementInfo
|
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
|
||||||
{
|
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
|
||||||
Type = 12,
|
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Test 5c: Type 1 → UiButton ──────────────────────────────────────────
|
// ── Test 5c: Type 1 → UiButton ──────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,13 @@ public class LayoutImporterTests
|
||||||
Assert.Equal(150f, found.Width);
|
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>
|
/// <summary>
|
||||||
/// A root with two children: one Type-12 style prototype and one Type-3 container.
|
/// A root with two children: one Type-12 UIElement_Text and one Type-3 container.
|
||||||
/// The Type-12 must be absent from the tree (FindElement returns null);
|
/// The Type-12 must appear as a <see cref="UiText"/> in the tree (transparent,
|
||||||
/// the Type-3 must be present.
|
/// draws nothing until a controller binds its <c>LinesProvider</c>);
|
||||||
|
/// the Type-3 must also be present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
|
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
|
||||||
|
|
@ -48,9 +49,9 @@ public class LayoutImporterTests
|
||||||
|
|
||||||
var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null);
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null);
|
||||||
|
|
||||||
// Type-12 must be absent.
|
// Type-12 is now a UiText (transparent, no lines) — present in the tree.
|
||||||
Assert.Null(tree.FindElement(0x20000001));
|
Assert.IsType<UiText>(tree.FindElement(0x20000001));
|
||||||
// Type-3 must be present.
|
// Type-3 must also be present.
|
||||||
Assert.NotNull(tree.FindElement(0x20000002));
|
Assert.NotNull(tree.FindElement(0x20000002));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ using Xunit;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI;
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
public class UiChatViewDatFontTests
|
public class UiTextDatFontTests
|
||||||
{
|
{
|
||||||
// Synthetic per-char advance: each glyph 10px (Before=2,Width=6,After=2).
|
// Synthetic per-char advance: each glyph 10px (Before=2,Width=6,After=2).
|
||||||
private static FontCharDesc Glyph(char c) => new()
|
private static FontCharDesc Glyph(char c) => new()
|
||||||
|
|
@ -17,9 +17,9 @@ public class UiChatViewDatFontTests
|
||||||
public void CharIndexAt_UsesDatGlyphAdvance()
|
public void CharIndexAt_UsesDatGlyphAdvance()
|
||||||
{
|
{
|
||||||
float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c));
|
float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c));
|
||||||
Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f));
|
Assert.Equal(0, UiText.CharIndexAt("abc", Adv, 4f));
|
||||||
Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f));
|
Assert.Equal(1, UiText.CharIndexAt("abc", Adv, 12f));
|
||||||
Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f));
|
Assert.Equal(3, UiText.CharIndexAt("abc", Adv, 100f));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -5,28 +5,28 @@ using AcDream.App.UI;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI;
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
public class UiChatViewTests
|
public class UiTextTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClampScroll_PinsToZero_WhenContentFitsView()
|
public void ClampScroll_PinsToZero_WhenContentFitsView()
|
||||||
{
|
{
|
||||||
// 5 lines of content in a taller view → nothing to scroll, pinned at 0.
|
// 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, UiText.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f));
|
||||||
Assert.Equal(0f, UiChatView.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f));
|
Assert.Equal(0f, UiText.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClampScroll_CapsAtContentMinusView_WhenOverflowing()
|
public void ClampScroll_CapsAtContentMinusView_WhenOverflowing()
|
||||||
{
|
{
|
||||||
// Content 500, view 200 → max scrollback is 300px (oldest line at top).
|
// Content 500, view 200 → max scrollback is 300px (oldest line at top).
|
||||||
Assert.Equal(300f, UiChatView.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f));
|
Assert.Equal(300f, UiText.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f));
|
||||||
Assert.Equal(120f, UiChatView.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f));
|
Assert.Equal(120f, UiText.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClampScroll_NeverNegative()
|
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 ──
|
// ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ──
|
||||||
|
|
@ -36,39 +36,39 @@ public class UiChatViewTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CharIndexAt_ZeroOrNegative_IsColumnZero()
|
public void CharIndexAt_ZeroOrNegative_IsColumnZero()
|
||||||
{
|
{
|
||||||
Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, 0f));
|
Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 0f));
|
||||||
Assert.Equal(0, UiChatView.CharIndexAt("hello", Mono10, -5f));
|
Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, -5f));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CharIndexAt_SnapsToGlyphMidpoint()
|
public void CharIndexAt_SnapsToGlyphMidpoint()
|
||||||
{
|
{
|
||||||
// glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ...
|
// 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(0, UiText.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, UiText.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0
|
||||||
Assert.Equal(1, UiChatView.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1
|
Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1
|
||||||
Assert.Equal(2, UiChatView.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1
|
Assert.Equal(2, UiText.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CharIndexAt_PastEnd_IsLength()
|
public void CharIndexAt_PastEnd_IsLength()
|
||||||
{
|
{
|
||||||
Assert.Equal(5, UiChatView.CharIndexAt("hello", Mono10, 1000f));
|
Assert.Equal(5, UiText.CharIndexAt("hello", Mono10, 1000f));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CharIndexAt_EmptyString_IsZero()
|
public void CharIndexAt_EmptyString_IsZero()
|
||||||
{
|
{
|
||||||
Assert.Equal(0, UiChatView.CharIndexAt("", Mono10, 50f));
|
Assert.Equal(0, UiText.CharIndexAt("", Mono10, 50f));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SelectedText assembly ────────────────────────────────────────────
|
// ── 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)
|
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;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ public class UiChatViewTests
|
||||||
public void SelectedText_SingleLine_Substring()
|
public void SelectedText_SingleLine_Substring()
|
||||||
{
|
{
|
||||||
var lines = Lines("hello world");
|
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);
|
Assert.Equal("world", s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ public class UiChatViewTests
|
||||||
{
|
{
|
||||||
var lines = Lines("hello world");
|
var lines = Lines("hello world");
|
||||||
// caret BEFORE anchor — Order() must normalise.
|
// 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);
|
Assert.Equal("world", s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ public class UiChatViewTests
|
||||||
public void SelectedText_SamePosition_IsEmpty()
|
public void SelectedText_SamePosition_IsEmpty()
|
||||||
{
|
{
|
||||||
var lines = Lines("hello");
|
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]
|
[Fact]
|
||||||
|
|
@ -101,7 +101,7 @@ public class UiChatViewTests
|
||||||
{
|
{
|
||||||
var lines = Lines("first line", "second line", "third line");
|
var lines = Lines("first line", "second line", "third line");
|
||||||
// from col 6 of line 0 ("line") through col 5 of line 2 ("third")
|
// 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);
|
Assert.Equal("line\nsecond line\nthird", s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ public class UiChatViewTests
|
||||||
public void SelectedText_MultiLine_TwoLines_NoMiddle()
|
public void SelectedText_MultiLine_TwoLines_NoMiddle()
|
||||||
{
|
{
|
||||||
var lines = Lines("alpha", "bravo");
|
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);
|
Assert.Equal("pha\nbra", s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,26 +118,26 @@ public class UiChatViewTests
|
||||||
{
|
{
|
||||||
var lines = Lines("alpha", "bravo");
|
var lines = Lines("alpha", "bravo");
|
||||||
// end before start → Order() swaps them.
|
// 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);
|
Assert.Equal("pha\nbra", s);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectedText_EmptyLineList_IsEmpty()
|
public void SelectedText_EmptyLineList_IsEmpty()
|
||||||
{
|
{
|
||||||
Assert.Equal("", UiChatView.SelectedText(Array.Empty<UiChatView.Line>(),
|
Assert.Equal("", UiText.SelectedText(Array.Empty<UiText.Line>(),
|
||||||
new UiChatView.Pos(0, 0), new UiChatView.Pos(0, 0)));
|
new UiText.Pos(0, 0), new UiText.Pos(0, 0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Order_SortsByLineThenColumn()
|
public void Order_SortsByLineThenColumn()
|
||||||
{
|
{
|
||||||
var (s1, e1) = UiChatView.Order(new UiChatView.Pos(2, 1), new UiChatView.Pos(0, 5));
|
var (s1, e1) = UiText.Order(new UiText.Pos(2, 1), new UiText.Pos(0, 5));
|
||||||
Assert.Equal(new UiChatView.Pos(0, 5), s1);
|
Assert.Equal(new UiText.Pos(0, 5), s1);
|
||||||
Assert.Equal(new UiChatView.Pos(2, 1), e1);
|
Assert.Equal(new UiText.Pos(2, 1), e1);
|
||||||
|
|
||||||
var (s2, e2) = UiChatView.Order(new UiChatView.Pos(1, 8), new UiChatView.Pos(1, 2));
|
var (s2, e2) = UiText.Order(new UiText.Pos(1, 8), new UiText.Pos(1, 2));
|
||||||
Assert.Equal(new UiChatView.Pos(1, 2), s2);
|
Assert.Equal(new UiText.Pos(1, 2), s2);
|
||||||
Assert.Equal(new UiChatView.Pos(1, 8), e2);
|
Assert.Equal(new UiText.Pos(1, 8), e2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue