acdream/src/AcDream.App/UI
Erik 989f665214 feat(CT-GF1): port retail ancestor-clip to the retained UI tree
Fixes the CT7 gate finding: on the Titles tab, the authored divider
0x10000530 escapes the Character window above its top edge at the
CT6-correct 372px mounted default (computed Y ~ -178, matching the
owner's screenshot). Retail clips child rendering to the intersected
ancestor clip-rect chain -- UIRegion::DrawHere @0x0069FA30 takes the
element's screen Box2D plus a SmartArray<Box2D> of inherited clip
rects, intersects them (the min/max clamp loop @0x0069FAA7..0x0069FB82),
and draws EraseSelf/DrawChildren/DrawSelf with the intersected rect
only when non-empty (the var_24 gate @0x0069FB8E). Our UiElement draw
walk rendered children unclipped by default, so any authored element
relying on clipping -- this divider, and the chat input row at small
window sizes (the owner's earlier "text input sticks out on resize"
report) -- became a visible artifact.

Mechanism (element-level, reusing the existing clip-rect-stack
infrastructure in UiRenderContext.PushClip/PopClip):

- UiElement.ClipsChildren now defaults to TRUE for every element
  (was an opt-in used only by UiScrollablePanel/UiItemList). Each
  element's children draw AND hit-test clipped to the intersection
  of its own rect with the inherited ancestor clip; an element
  positioned outside its parent's box silently disappears, matching
  retail's non-empty-intersection gate. HitTest's existing early
  bounds check already implemented this shape for ClipsChildren=true
  elements -- flipping the default aligns hit-testing with the new
  draw-clip default in one property, per the plan's own point 4.

- UiElement.ExpandsClipForPopup (default false) is the one opt-out:
  retail spawns a menu popup as a SEPARATE top-level region
  (UIElement_Menu::MakePopup), clipped only by the screen; acdream
  draws UiMenu's popup inline from the owning button in a second
  traversal (OnDrawOverlay, pre-existing -- its own doc comment
  already says "regardless of this element's position in the tree").
  DrawOverlays now resets the accumulated clip to unbounded
  (UiRenderContext.PushClipUnbounded, sharing the existing clip
  stack) for exactly the OnDrawOverlay call of an opted-in element.
  UiMenu overrides ExpandsClipForPopup=>true, paired with
  ClipsChildren=>false so its own out-of-bounds OnHitTest union (the
  popup occupies ly<0 or ly>=Height depending on open direction)
  stays reachable through the same early-bounds gate that now
  defaults on for every other element.

Opt-out audit (grep for OnDrawOverlay overrides + negative/overflow
OnDraw coordinates across src/AcDream.App/UI): UiMenu's popup is the
ONLY OnDrawOverlay override client-wide, so it is the only element
needing ExpandsClipForPopup. RetailTooltipPresenter's popup and
UiRoot's drag ghost both already escape structurally -- the tooltip
mounts as an ordinary UiRoot CHILD (sibling of every window, clipped
only by the canvas), and the drag ghost is drawn directly by UiRoot
outside the tree entirely -- neither needed a code change, both are
covered by new tests proving the invariant. UiResizeGrip and
UiNineSlicePanel's frame/bevel draw entirely within their own
[0,Width]x[0,Height] (grip flush at the window's own edges; the
window's own Width/Height already represents the OUTER frame
including its 5px bevel, so its ClipsChildren push already covers
the frame's own content children correctly -- no negative insets
found). UiScrollbar draws entirely within its own bounds (confirmed
by reading OnDraw).

Hit-testing: aligned with the new default via the single
ClipsChildren flip (see above); UiMenu's own opt-out override keeps
its popup hit-test union working, verified by the full UiMenuTests
suite staying green.

Divergence register: AD-113 filed for the ExpandsClipForPopup
adaptation (inline popup drawing vs retail's separate top-level
region).

Fixed two pre-existing test-harness gaps the new default surfaced
(both real bugs in the harnesses, not workarounds around the fix):
- ChatLayoutConformanceTests' bottom-right-grip grow test read a
  STALE (pre-shrink) grip screen position because it drove two resize
  gestures back-to-back with no intervening Draw pass -- the only
  place UiElement.ApplyAnchor/LayoutPolicy.Apply run. A real frame
  draws every tick, so production never hits this; the test now
  inserts a real DrawSelfAndChildren pass between the two gestures,
  matching a real frame boundary.
- VendorUiControllerTests' hand-built Items/Buying/Selling page
  containers were left at their bare 0x0 UiElement default (the
  harness never runs a real DAT-driven layout pass) -- harmless
  before ancestor clipping existed, but now hides every child of an
  unsized page. Sized them to the window's own content root, matching
  production's shape (a tab page fills the window body).

Tests (all confirmed as genuine regression pins by temporarily
reverting the relevant default/override and observing the exact
predicted failure, then reverting back):
- CharacterTitlesControllerTests.TitlesPage_Divider_ClipsAwayAtThe
  CT6Default_AndAppearsWhenTheWindowGrowsTaller: the literal gate
  repro against the real character_2100002E.json fixture through
  RetailWindowFrame.Mount at the CT6 372px default -- the divider
  renders nothing (computed Y ~ -173, matching the owner's ~-178);
  growing the window to 600px renders it at its authored spot.
- ChatLayoutConformanceTests.ResizingTheWindowSmall_NoInputRowQuad
  RendersOutsideTheWindowRect: no input-row quad escapes the chat
  window rect at three small sizes (300x100 sanity control,
  120x40/80x30 genuine pre-fix overflow -- verified failing without
  the fix at Y=38/55 past the window edge).
- UiAncestorClipTests (new file): the core mechanism against plain
  synthetic elements (culled-outside / clipped-at-the-edge / hit-test
  parity), UiMenu's popup escaping a tiny owning window (and staying
  clipped while closed), and the tooltip's structural immunity
  (mounts as a UiRoot sibling, unaffected by a tiny ancestor window).

Verification: full solution build green; hermetic suite green
(--filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&
Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&
Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure",
14,000+ tests across every project); InstalledDat lane green
(ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure,
205+34+3+172 tests). CharacterTitlesControllerTests' existing suite
and the full UiMenuTests/UiScrollbarTests suites are unaffected.

src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) -- untouched by
this change and deliberately left out of this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 06:29:15 +02:00
..
Layout fix(CT6): fix round — chrome-inclusive host clamp (BLOCKER B1) + 372px mount default 2026-08-25 02:53:57 +02:00
Testing test(gates): sequence the two-client remote-shadow gate with harness signals 2026-08-22 17:44:13 +02:00
AutoWieldController.cs fix(items): port retail useability and autowear rejection 2026-07-23 08:33:31 +02:00
ChatTranscriptLogWriter.cs feat(chat): CT-B4 — @log, and the research block that was a wrong question 2026-08-21 11:28:20 +02:00
ClientCommandController.cs feat(chat): CT-B4 — @log, and the research block that was a wrong question 2026-08-21 11:28:20 +02:00
ControlsIni.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
CursorFeedbackController.cs fix(ui): restore retail vitals and window interactions 2026-08-20 13:26:35 +02:00
GameplayConfirmationController.cs fix(ui,runtime): Campaign LA gate-round-2 batch-review fixes F1,F3-F8; file #401 2026-08-15 12:28:06 +02:00
IconComposer.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
IItemListDragHandler.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
IRetainedPanelController.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
IRetainedWindowStateController.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
ItemDragPayload.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
ItemEquipRules.cs feat(ui): D.2b item interaction + retail cursors + live character sheet 2026-07-03 09:18:43 +02:00
ItemInteractionController.cs fix #435 (part 2, closes it): attribute the unowned probes — delete 7, reclassify 8, restore 1 2026-08-24 12:32:42 +02:00
IUiChildrenAttachedListener.cs feat(ui): Campaign OP slice OP2 — tab control, template ListBox, UIOption widget mappings 2026-08-11 00:06:43 +02:00
IUiDatStateful.cs fix(chargen): Campaign CC gate round 1 Batch B — authored selection states, label state, zoom/swatch feedback 2026-08-16 11:37:09 +02:00
IUiGlobalTimeListener.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
IUiViewportRenderer.cs feat(render): Campaign V slice V6l commit 3 - the offscreen viewports draw on Vulkan 2026-07-28 18:05:24 +02:00
JournalPersistence.cs feat(journal): QJ3/QJ4/QJ5 — both remaining tabs are live 2026-08-21 15:42:05 +02:00
MarkupDocument.cs fix(mosstank): unclickable button, empty skill list, dev font, and chat output 2026-08-20 18:27:28 +02:00
README.md refactor(ui): extract retained runtime composition 2026-07-11 00:03:45 +02:00
RetailChromeSprites.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
RetailCursorCatalog.cs fix(ui): port retail window control feedback 2026-07-17 09:40:08 +02:00
RetailItemConfirmationController.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
RetailPanelCatalog.cs feat(quest): QT5/QT6 — the Journal panel, and the button that was already there 2026-08-21 15:12:05 +02:00
RetailScrollbarChrome.cs fix(ui): retail scrollbar parity — button seating, full-track thumb, hover/pressed states 2026-08-24 19:20:43 +02:00
RetailSkillTrainingConfirmationController.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
RetailUiRuntime.cs fix(CT6): fix round — chrome-inclusive host clamp (BLOCKER B1) + 372px mount default 2026-08-25 02:53:57 +02:00
RetailUiRuntimeLease.cs refactor(app): extract focused window lifetime 2026-07-22 19:43:52 +02:00
RetailWindowHandle.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
RetailWindowLayoutPersistence.cs fix(ui): restore retail vitals and window interactions 2026-08-20 13:26:35 +02:00
RetailWindowLockPresentationController.cs fix(ui): restore retail vitals and window interactions 2026-08-20 13:26:35 +02:00
RetailWindowManager.cs fix(ui): restore retail vitals and window interactions 2026-08-20 13:26:35 +02:00
RetailWindowOpacityController.cs fix #379: chat opacity fade was scoped to every window, not just chat 2026-08-11 23:00:57 +02:00
RetainedPanelControllerGroup.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
RetainedUiInputBinding.cs refactor(input): own pointer and callback lifetime 2026-07-22 11:59:33 +02:00
SpewBoxController.cs feat(chat): retail text style — two-plane glyph outlines, authored SpewBox/chat styles 2026-08-10 19:28:34 +02:00
UiButton.cs fix(ui): button property 0x0D was never "disabled", and it killed every Journal button 2026-08-21 15:58:03 +02:00
UiButtonStateMachine.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
UiCatalogSlot.cs feat: regen buffs, wand aura, spellbook assess, indicator press flash 2026-08-20 20:38:28 +02:00
UiCheckboxBitfield64.cs feat(ui): Campaign OP slice OP5 — the Chat tab 2026-08-11 06:25:59 +02:00
UiCollapsibleFrame.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
UiDatFont.cs feat(chat): retail text style — two-plane glyph outlines, authored SpewBox/chat styles 2026-08-10 19:28:34 +02:00
UiDialogRoot.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
UiElement.cs feat(CT-GF1): port retail ancestor-clip to the retained UI tree 2026-08-25 06:29:15 +02:00
UiEvent.cs fix(ui): OP5 re-check residuals R1/R2 (coordinator pass) — OP5 CLOSED 2026-08-11 08:13:24 +02:00
UiField.cs fix(ui): chat window retail parity — focus rails, authored captions, menu flick, drag-stable text 2026-08-24 20:27:04 +02:00
UiHost.cs feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend 2026-07-29 02:19:53 +02:00
UiItemList.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
UiItemSlot.cs fix(ui): night-round review — F9/F10/F12 register + structure riders 2026-08-17 04:56:16 +02:00
UiLayoutPolicy.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
UiMenu.cs feat(CT-GF1): port retail ancestor-clip to the retained UI tree 2026-08-25 06:29:15 +02:00
UiMeter.cs fix(ui): gate — no void frames around the login wormhole; vitals icons centered 2026-08-17 12:30:51 +02:00
UiNineSlicePanel.cs fix(ui): restore retail vitals and window interactions 2026-08-20 13:26:35 +02:00
UiOptionToggleSlider.cs feat(ui): Campaign OP slice OP2 — tab control, template ListBox, UIOption widget mappings 2026-08-11 00:06:43 +02:00
UiPanel.cs fix(ui): Campaign CT5 fix round — Normal-state row media, geometry test, padding, doc corrections 2026-08-25 01:47:25 +02:00
UiRadar.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
UiRenderContext.cs feat(CT-GF1): port retail ancestor-clip to the retained UI tree 2026-08-25 06:29:15 +02:00
UiResizeGrip.cs fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture 2026-08-10 13:09:32 +02:00
UiRoot.cs fix(ui): morning gate — world tooltips ride retail's mouse-idle dwell, not the found edge 2026-08-17 08:49:11 +02:00
UiScrollable.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
UiScrollablePanel.cs fix #371: straddling rows clip at the viewport edge instead of vanishing whole 2026-08-11 17:08:21 +02:00
UiScrollbar.cs fix(ui): disabled scrollbars keep retail hover hot-tracking 2026-08-24 19:54:28 +02:00
UiShortcutDigitGraphics.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
UiSolidSpriteFill.cs fix #381: Options-panel footer needs an opaque backing field 2026-08-11 23:01:43 +02:00
UiTabPanel.cs feat(ui): Campaign OP slice OP4 — the Character tab 2026-08-11 04:31:34 +02:00
UiTemplateListBox.cs feat(render): implement Campaign AR and terrain fidelity 2026-08-22 13:13:29 +02:00
UiText.cs feat(chat): CT-A5 — clicking a speaker's name opens a tell 2026-08-21 07:34:58 +02:00
UiTextureElement.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00
UiViewport.cs feat(render): Campaign V slice V6l commit 3 - the offscreen viewports draw on Vulkan 2026-07-28 18:05:24 +02:00
WindowNames.cs feat(quest): QT5/QT6 — the Journal panel, and the button that was already there 2026-08-21 15:12:05 +02:00
WorldDropDispatch.cs Revert "Campaign V slice V4a" - it lost world multisampling 2026-07-27 18:29:28 +02:00

AcDream.App.UI — Retail-style UI toolkit

This is acdream's retained-mode UI toolkit. It mirrors the behavior of the retail AC client (hit-testing, modal, capture, drag-drop, tooltip delay, focus routing, event type codes) without trying to byte-match the retail binary — because the retail widgets live in keystone.dll, which we don't decompile.

Research

All design decisions in this directory are grounded in the master synthesis + six deep-dive docs under docs/research/retail-ui/:

Document Topic
00-master-synthesis.md Cross-slice synthesis + C# port plan
01-architecture-and-init.md Process entry, window, main loop
02-class-hierarchy.md CUIManager / CUIListener / CFont / CSurface
03-rendering.md Font atlas, 2D quad batch, cursor
04-input-events.md WndProc → Device → widget event routing
05-panels.md Chat, attributes, spells, paperdoll, inventory
06-hud-and-assets.md Vital orbs, radar, compass + dat asset catalog

Files

  • UiEvent.cs — 24-byte event struct + retail-faithful type constants (0x01 click, 0x15 drag-begin, 0x3E drop, 0x201 WM_LBUTTONDOWN, …)
  • UiElement.cs — base widget with OnDraw / OnEvent / OnHitTest / OnTick virtuals, children list, ZOrder, focus/capture flags
  • UiPanel.csUiPanel (rect + optional bg/border), UiLabel, UiButton
  • UiRenderContext.cs — per-frame draw context with translate stack
  • UiRoot.cs — top-of-tree + "Device" responsibilities (mouse/keyboard state, focus, modal, capture, drag-drop, tooltip timer). Mirrors the retail DAT_00837ff4 Device object's vtable.
  • RetailWindowManager.cs / RetailWindowHandle.cs — named top-level window registry, raise policy, visibility/focus/capture lifecycle, geometry events, lock propagation, and controller teardown.
  • IRetainedPanelController.cs — disposable lifecycle contract for panel-specific show/hide and descendant-focus behavior.
  • RetainedPanelControllerGroup.cs — one typed lifecycle owner for windows whose imported subtree is bound by multiple focused controllers; teardown is idempotent and runs in reverse construction order.
  • RetailWindowLayoutPersistence.cs / IRetainedWindowStateController.cs — per-character/per-resolution bounds, visibility, collapse/maximize, legacy migration, and safe resolution-clamped restore.
  • RetailUiRuntime.cs — production composition owner. Focused binding records carry state/action delegates and DAT/GL resolvers in; the runtime imports and mounts every gameplay window and owns controller, cursor, persistence, probe, tick, draw, restore, and disposal paths.
  • Layout/RetailWindowFrame.cs — the single production/Studio mount contract for imported-chrome and shared-wrapper windows.
  • UiHost.cs — one-shot wrapper: owns the UiRoot, a TextRenderer, and a default BitmapFont. Provides WireMouse / WireKeyboard helpers for Silk.NET plumbing and removes those device subscriptions before disposing the window manager/controllers and renderer.

Integration pattern

// GameWindow.OnLoad
_uiHost = new UiHost(_gl!, shadersDir, _debugFont);
_uiHost.Root.WorldMouseFallThrough += (btn, x, y, flags) => HandleWorldClick(btn, x, y, flags);
_uiHost.Root.WorldKeyFallThrough   += (vk, lp) => HandleHotkey(vk);
foreach (var mouse in _input.Mice) _uiHost.WireMouse(mouse);
foreach (var kb in _input.Keyboards) _uiHost.WireKeyboard(kb);

// Add panels
var chat = new Panels.ChatWindow { Left = 10, Top = 400, Width = 500, Height = 250 };
_uiHost.Root.AddChild(chat);

// GameWindow.OnRender — after the 3D scene
_uiHost.Tick(deltaSeconds);
_uiHost.Draw(new Vector2(_window!.Size.X, _window.Size.Y));

What's scaffolded vs what still needs building

Shipped in the scaffold (this session)

  • UI tree + event routing + focus + modal + capture + drag-drop
  • Hit-testing (children-first, Z-order tie-break)
  • Tooltip timer (~1000ms)
  • Hover enter/leave, click vs right-click, scroll, keyboard
  • World fall-through so existing camera/player controls still work
  • Simple text/rect drawing through the existing BitmapFont + TextRenderer pipeline

To build next

  1. AcFont + FontCache — load Font DBObjs from portal.dat range 0x40000000..0x40000FFF, bake 256×256 glyph atlas from the referenced RenderSurface (0x06xxxxxx). See slice 03 §4.
  2. Dat sprite loader — decode RenderSurface dats as GL textures; add DrawSprite(uint datId, Rectangle dest, uint rgba) to UiRenderContext.
  3. CursorManager — OS cursor + dat-sourced custom cursors via slice 03 §7.
  4. Scissor clipping — for panels with scrollable interiors (chat, inventory grid). GL_SCISSOR_TEST wrapped in UiRenderContext.PushScissor / PopScissor.
  5. First concrete panel — ChatWindow since we have all 6 wire messages parsed already. See slice 05 §1.
  6. Vital orbs HUD once the server sends GameMessagePrivateUpdateVital. See slice 06 A.1.

Retail magic numbers the scaffold preserves

Because hand-ported panel code will copy the retail switch-case structure, we keep the magic constants:

// Event types
UiEventType.Click          == 0x01   // chunk_00470000.c ~11140
UiEventType.Tooltip        == 0x07   // chunk_00460000.c ~6253 (~1000ms delay)
UiEventType.DragBegin      == 0x15   // chunk_004A0000.c ~2707
UiEventType.DragEnter      == 0x21   // chunk_004A0000.c ~2714
UiEventType.DragOver       == 0x1C   // chunk_004A0000.c ~2723
UiEventType.DropReleased   == 0x3E   // chunk_004A0000.c ~2754
UiEventType.MouseDown      == 0x201  // WM_LBUTTONDOWN
UiEventType.MouseUp        == 0x202  // WM_LBUTTONUP

// Event IDs
// Widget event IDs live in the 0x10000000+ range (retail convention).
// UiRoot auto-assigns EventIds starting at 0x10000001.