12 KiB
LayoutDesc Importer — Design
Date: 2026-06-15 Status: Approved (brainstorm) — pending spec review → implementation plan Track: D.2b retail UI engine (next sub-phase; register the phase id in the roadmap before implementation) Supersedes nothing. Deletes nothing. Coexists with the existing hand-authored path.
Context
D.2b shipped a working retail vitals window and a scrollable chat window, but each was
built by hand: dump the dat LayoutDesc, transcribe sprite ids + rects into
vitals.xml / UiMeter / UiNineSlicePanel, then discover-and-patch missing details
(the bar fill model, the dat-font, the tiling, the resize-grip overlay) one at a time.
That archaeology does not scale to AC's dozens of windows, and it keeps missing details
that are already in the dat (the grip overlay was found only because the user spotted it).
The LayoutDesc dat is a complete, declarative description of every window — element
tree, positions, sizes, anchors, sprites per state, draw-modes, fonts, borders, grips,
meters, labels, inheritance. It is retail's "HTML for windows." The fix is to render the
dat with one faithful interpreter rather than transcribe it per window.
Goal
Build a faithful LayoutDesc interpreter that reads a retail layout from the dat and
produces a UiElement tree the existing toolkit renders — so opening any retail window is
one call, with no per-window graphics/layout code. The only per-window code is live
data wiring (which is inherently per-window and tiny).
Non-goals
- Re-porting Keystone's C++ framework (its own renderer, string/container classes, vtable dispatch, D3D blits). We port retail's render algorithms, not its framework — that is what Silk.NET + .NET already provide. (See "Decisions → Structure".)
- Deleting or rewriting the existing toolkit/widgets/markup. They are reused.
Decisions (from brainstorm 2026-06-15)
- Proof target = re-drive vitals. Point the importer at the vitals
LayoutDesc(0x2100006C) and make it reproduce the hand-built window. Known-good baseline → clean pass/fail. The hand-authored vitals path stays as the reference until the importer matches. - Scope = full faithful interpreter. Interpret the complete
LayoutDescformat (every element type, fullBaseElement/BaseLayoutIdinheritance, all draw-modes, states, properties) — not just the slice vitals uses. Matches the project's "behavior is retail" ethos. - Structure = hybrid (Approach C). Port each element type's render algorithm verbatim from the decomp, onto our modern draw primitives. A single generic renderer handles the trivial "stamp the sprite per draw-mode" types (the long tail, including types not yet catalogued); dedicated widgets handle types with real behavior (meter, text, scrollbar/chat, button). The decomp's render method for each type decides which bucket it falls in — we do not guess. Faithfulness comes from porting the algorithms; the hybrid is only about C# packaging.
- Coexistence, don't-delete.
MarkupDocumentstays as the path for plugin/custom panels (no dat layout). The existing widgets (UiMeter,UiNineSlicePanel,UiChatView,UiDatFont) and primitives (tiling, scissor-fill, dat-font, nine-slice) become the importer's behavioral renderers.
Architecture & data flow
RETAIL WINDOWS (data-driven from the dat)
client_portal.dat ─► LayoutImporter ─► UiElement tree ─► UiRoot ─► renderers ─► screen
(LayoutDesc 0x21..) │ (UiDatElement +
│ behavioral widgets)
├─ resolve BaseElement / BaseLayoutId inheritance
├─ walk ElementDesc tree → widget (hybrid factory)
└─ apply rect / anchors / states / media / props from the dat
per-window Controller ─► binds LIVE data to elements by id (mirrors retail gm*UI)
WindowManager ─► open/close by layout id, z-order, focus, position persistence
PLUGIN / CUSTOM PANELS (hand-authored, unchanged)
*.xml ─► MarkupDocument ─► UiElement tree ─► (same UiRoot + renderers)
Two input paths (dat importer for retail windows, markup for custom/plugin), one rendering
toolkit. Nothing in the bottom (UiHost/UiRoot/UiElement) or the render primitives
changes.
Components
1. Format enumeration (Step 0 — foundational groundwork)
Because we chose "full faithful," the first deliverable is a documented map of the complete format, not code. Sources, cross-checked against each other:
- DatReaderWriter types —
ElementDesc,StateDesc,MediaDesc*and their enums (Type,DrawMode, media kinds, state keys). Reflect/inspect asdump-vitals-layoutalready does (props and fields). - Retail decomp — the
UIElement_*class hierarchy + each type's render method; the property-key meanings; the KSML keyword registrations (the parser registers every property name — the canonical vocabulary, e.g.KW_DRAWMODE,KW_DURATION, …). - Real layouts — scan a sample of
LayoutDescs to confirm which Types/properties actually occur and catch anything the above missed.
Output: a reference doc mapping each Type → meaning + render method, each property key →
meaning, each DrawMode → behavior, and the inheritance rules. This doc drives every other
component and is committed alongside the importer.
2. LayoutImporter
Reads a LayoutDesc by id and returns a UiElement subtree:
- Walk the
ElementDesctree. - For each element: resolve inheritance (§3), pick a widget via the factory (§4), set its
rect (
X/Y/W/H), anchors (edge flags →AnchorEdges), z-order, states, media, and properties from the (resolved) element. - Recurse into children.
- Expose
FindElement(uint id)on the result so controllers wire by id.
Depends on: DatCollection (read layouts), the factory, the inheritance resolver,
TextureCache.GetOrUploadRenderSurface (sprites), UiDatFont (text). No GL itself — it
builds UiElements; rendering stays in the toolkit.
3. Inheritance resolution
An element with BaseElement/BaseLayoutId inherits the base element's properties / states
/ media; the derived element overrides. Resolve by loading the base layout, finding the base
element, and merging (base first, then derived overrides) before instantiating.
Required even for vitals: the number-text element inherits its font/style from base layout
0x2100003F. Cycle-guard the resolution.
4. Hybrid widget factory (Type → renderer)
- Behavioral types → dedicated widgets (verbatim-algorithm ports): meter →
UiMeter, text → dat-font label, scrollable/list region →UiChatView/list widget, button →UiButton, resizable window root →UiNineSlicePanel. - Trivial types (image, container, border piece, grip) →
UiDatElement(generic). - Unknown type →
UiDatElement(faithful fallback — still draws its media).
The Step-0 enumeration assigns each Type to a bucket by reading its retail render method
(trivial blit → generic; real algorithm → widget).
5. UiDatElement (generic renderer)
A UiElement holding the resolved element's active-state media + draw-mode + rect. Its
OnDraw ports retail's base blit branch:
Normal→ tile at native size (UV-repeat; the UI texture isGL_REPEAT-wrapped) — the mechanism already proven for the bars + chrome.Alphablend→ blended overlay.Stretch(if present) → scale.- image → sprite; cursor → hover cursor. Reuses the tiling, dat-font, nine-slice draw primitives.
6. Per-window controllers (live-data binding)
Mirror retail's gm*UI classes. A small controller per window grabs elements by id from the
imported tree and pushes live data in: VitalsController binds HealthPercent → meter fill,
cur/max → number text; ChatController binds the chat tail → the chat region. This is
the only per-window code, and it is data wiring, not graphics. Retail-faithful: e.g.
gmVitalsUI::PostInit grabs child meter elements by id and sets attribute 0x69 (fill).
7. WindowManager
OpenWindow(layoutId, controller) → import + attach to UiRoot, place at the dat's default
position (then persist user move/resize), manage z-order / focus / close. Orchestrates the
focus/drag/resize mechanics UiRoot already provides.
8. States / expand / hover
Each element carries its named states (HideDetail/ShowDetail, normal/hover/pressed) from
the dat; the active state selects which media draws. A click or hover flips the active state.
Click-to-expand and hover highlight fall out generically — no per-window code.
Rollout order (milestones)
- Enumerate the format (§1) → reference doc.
LayoutImporter+ inheritance resolution (read + resolve + walk).UiDatElementgeneric renderer (port the draw-mode blit branch).- Hybrid factory (Type → widget/generic).
VitalsController(bind by id).- Re-drive vitals → diff against the current window. ✅ conformance gate.
WindowManager(open/close/persist).- Extend to chat (
ChatController), then new windows for free.
Testing / conformance
- Golden tree checks — the importer-built vitals tree has the expected element rects,
resolved sprites, and active states (assert against the known
0x2100006Cvalues). - Inheritance unit tests — base+override merge, cycle-guard.
- Draw-mode unit tests — the UV math for tile vs stretch vs the partial last tile.
- Bind-by-id unit tests — controller wires the right element.
- Headless visual diff —
render-vitals-mockup/ a tree-render comparison vs the hand-built reference (no live server needed). - Final — in-client visual verification (the user) once the gate passes.
Coexistence / don't-delete (restated)
MarkupDocument+*.xmlstay for plugin/custom panels.UiMeter,UiNineSlicePanel,UiChatView,UiDatFont, the tiling/scissor-fill/dat-font/ nine-slice primitives stay — reused as the importer's behavioral renderers.- The hand-authored vitals path stays as the conformance reference until the importer matches it; only then is vitals flipped to the importer.
Risks & open questions
- The format enumeration is the foundational unknown. If a Type/property/draw-mode is mis-mapped, faithfulness breaks. Mitigation: Step 0 cross-checks three sources + real layouts; the vitals conformance gate catches regressions.
- Some behavioral types may need new widgets (list, scrollbar, edit box). These are generic, written once — not per-window. The generic fallback means an un-widgeted type still renders its sprites in the meantime.
- Position persistence scope (per-window saved rects) — minimal at first (dat default + in-session move/resize); durable persistence can follow.
- Phase id — register this as the next D.2b sub-phase in the roadmap before implementing.
Reference anchors
- Dat layouts: vitals (stacked)
0x2100006C; floaty row0x21000014; horizontal row0x21000075; vitals number-text base layout0x2100003F. - Decomp:
gmVitalsUI::PostInit@0x4bfce0(bind by id),UIElement_Meter::DrawChildren@0x46fbd0(scissor-fill),SurfaceWindow::DrawCharacter@0x442bd0(dat-font),ImgTex::TileCSI@0x53e740(tiling),UIRegion::DrawHere@0x69fa30(element draw order), the KSML keyword registrations (~0x71b540+). - Tools:
AcDream.Cli dump-vitals-layout(reflective full tree dump),dump-sprite-sheet(composite sprite ids),render-vitals-mockup(headless window render). - Memory:
project_d2b_retail_ui.md(the two-layout lesson, sprite ids, render model, dat-font, tools).