acdream/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md

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)

  1. 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.
  2. Scope = full faithful interpreter. Interpret the complete LayoutDesc format (every element type, full BaseElement/BaseLayoutId inheritance, all draw-modes, states, properties) — not just the slice vitals uses. Matches the project's "behavior is retail" ethos.
  3. 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.
  4. Coexistence, don't-delete. MarkupDocument stays 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 typesElementDesc, StateDesc, MediaDesc* and their enums (Type, DrawMode, media kinds, state keys). Reflect/inspect as dump-vitals-layout already 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 ElementDesc tree.
  • 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:

  • Normaltile at native size (UV-repeat; the UI texture is GL_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)

  1. Enumerate the format (§1) → reference doc.
  2. LayoutImporter + inheritance resolution (read + resolve + walk).
  3. UiDatElement generic renderer (port the draw-mode blit branch).
  4. Hybrid factory (Type → widget/generic).
  5. VitalsController (bind by id).
  6. Re-drive vitals → diff against the current window. conformance gate.
  7. WindowManager (open/close/persist).
  8. 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 0x2100006C values).
  • 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 diffrender-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 + *.xml stay 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 row 0x21000014; horizontal row 0x21000075; vitals number-text base layout 0x2100003F.
  • 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).