Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
319 lines
17 KiB
C#
319 lines
17 KiB
C#
using System;
|
|
using System.Linq;
|
|
using AcDream.App.UI;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
|
|
/// algorithm ports); everything else (and unknown Types) falls back to
|
|
/// <see cref="UiDatElement"/>.
|
|
///
|
|
/// <para>
|
|
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12
|
|
/// element is now built as a <see cref="UiText"/>. Elements that carry their own
|
|
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
|
|
/// prototype elements (no state media, no controller binding) draw nothing because
|
|
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
|
|
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
|
|
/// walks two layers down to extract them: the two Type-3 container children
|
|
/// ordered by <see cref="ElementInfo.ReadOrder"/> (back behind = lower, front
|
|
/// on top = higher), then within each container the image children that carry
|
|
/// a DirectState ("" key) sprite, ordered by their X position to obtain
|
|
/// left-cap / center-tile / right-cap.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The expand-detail overlay present in the front container carries ONLY named
|
|
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
|
|
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
|
|
/// automatically.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class DatWidgetFactory
|
|
{
|
|
/// <summary>
|
|
/// Creates the <see cref="UiElement"/> for <paramref name="info"/>, sets its
|
|
/// rect (Left/Top/Width/Height) and Anchors, and returns it.
|
|
/// </summary>
|
|
/// <param name="info">Resolved, merged element snapshot from the LayoutDesc importer.</param>
|
|
/// <param name="resolve">RenderSurface id → (GL tex handle, pixel width, pixel height).
|
|
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
|
/// <param name="datFont">Retail UI font for the meter's "cur/max" number overlay.
|
|
/// May be null pre-load — the meter falls back to the debug bitmap font.</param>
|
|
/// <param name="fontResolve">Optional font resolver: FontDid → <see cref="UiDatFont"/>
|
|
/// (or null when the font can't be loaded). When non-null, any element whose
|
|
/// <see cref="ElementInfo.FontDid"/> is non-zero gets ITS OWN dat font applied instead of
|
|
/// the shared <paramref name="datFont"/> fallback. Null = original behavior (use
|
|
/// <paramref name="datFont"/> for every element).</param>
|
|
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
|
|
public static UiElement? Create(ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
|
|
Func<uint, UiDatFont?>? fontResolve = null)
|
|
{
|
|
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
|
|
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
|
|
// containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT
|
|
// editable fields — retail draws those as inert media-bearing Fields, which our
|
|
// UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit
|
|
// affordance a UiField would add). The one true editable field, the chat input
|
|
// (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3
|
|
// stays on the generic fallback here; register it as UiField only when a window
|
|
// actually carries a factory-built editable Type-3 field (and UiField grows a
|
|
// background-media draw + an opt-in editable flag at that point). UiField (the widget)
|
|
// still ships — it just isn't wired into the factory switch yet.
|
|
// Resolve this element's own dat font if a resolver is provided and the element
|
|
// has a FontDid. Falls back to the shared datFont when not set (FontDid==0) or
|
|
// when the resolver returns null (font missing from dats).
|
|
UiDatFont? elementFont = datFont;
|
|
if (fontResolve is not null && info.FontDid != 0)
|
|
elementFont = fontResolve(info.FontDid) ?? datFont;
|
|
|
|
UiElement e = info.Type switch
|
|
{
|
|
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
|
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
|
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
|
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
|
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
|
12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655)
|
|
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
|
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
|
};
|
|
|
|
e.DatElementId = info.Id;
|
|
e.SetStateCursors(info.StateCursors);
|
|
|
|
// Propagate position + size (pixel-exact from the dat).
|
|
e.Left = info.X;
|
|
e.Top = info.Y;
|
|
e.Width = info.Width;
|
|
e.Height = info.Height;
|
|
|
|
// Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK — e.g. the
|
|
// gmInventoryUI full-window backdrop at ZLevel 100 sits behind the ZLevel-0 panels, #145);
|
|
// ReadOrder is the within-layer tiebreaker (higher = on top). K=10000 exceeds any window's
|
|
// element count so ZLevel always dominates. Vitals (all ZLevel 0) keep ZOrder == ReadOrder.
|
|
e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000;
|
|
|
|
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
|
|
// UI layout engine uses for reflow.
|
|
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
|
|
|
|
return e;
|
|
}
|
|
|
|
// ── Meter ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="UiMeter"/> and populates its sprite ids from the meter's
|
|
/// child/grandchild elements (format doc §11). Two shapes are handled:
|
|
///
|
|
/// <para>
|
|
/// <b>3-slice shape</b> (vitals meters — 2 Type-3 containers, each with 3 image grandchildren):
|
|
/// <code>
|
|
/// meter (Type 7)
|
|
/// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind)
|
|
/// │ ├── left-cap image (DirectState "" → File = back-left sprite)
|
|
/// │ ├── center image (DirectState "" → File = back-tile sprite)
|
|
/// │ └── right-cap image (DirectState "" → File = back-right sprite)
|
|
/// ├── front-layer container (Type 3, higher ReadOrder — drawn on top)
|
|
/// │ ├── left-cap image (→ front-left sprite)
|
|
/// │ ├── center image (→ front-tile sprite)
|
|
/// │ ├── right-cap image (→ front-right sprite)
|
|
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
|
|
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController)
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Single-image shape</b> (toolbar selected-object meters 0x100001A1/0x100001A2 — 1 Type-3
|
|
/// child, no grandchildren): the back-track sprite is on the meter element's own DirectState;
|
|
/// the fill sprite is on the single Type-3 child's own DirectState. Both are placed in the
|
|
/// TILE slot (Back/FrontTile) with left/right caps 0, so <see cref="UiMeter.DrawHBar"/> tiles
|
|
/// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction.
|
|
/// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328)
|
|
/// <code>
|
|
/// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E]
|
|
/// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F]
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <see cref="UiMeter.Fill"/> and <see cref="UiMeter.Label"/> are NOT set here.
|
|
/// They are bound to the live stat providers by the controller (VitalsController /
|
|
/// SelectedObjectController).
|
|
/// </para>
|
|
/// </summary>
|
|
private static UiMeter BuildMeter(ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
|
|
{
|
|
var m = new UiMeter
|
|
{
|
|
ElementId = info.Id,
|
|
SpriteResolve = resolve,
|
|
DatFont = datFont,
|
|
};
|
|
|
|
// The two 3-slice containers are Type-3 children of the meter element.
|
|
// ReadOrder determines draw order: the back track has a LOWER ReadOrder
|
|
// (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top).
|
|
var containers = info.Children
|
|
.Where(c => c.Type == 3)
|
|
.OrderBy(c => c.ReadOrder)
|
|
.ToList();
|
|
|
|
if (containers.Count >= 2)
|
|
{
|
|
// Vitals 3-slice shape: two Type-3 containers each holding 3 grandchild images
|
|
// (left-cap / center-tile / right-cap). Back is the lower ReadOrder; front is higher.
|
|
var (bl, bt, br) = SliceIds(containers[0]);
|
|
m.BackLeft = bl;
|
|
m.BackTile = bt;
|
|
m.BackRight = br;
|
|
|
|
var (fl, ft, fr) = SliceIds(containers[1]);
|
|
m.FrontLeft = fl;
|
|
m.FrontTile = ft;
|
|
m.FrontRight = fr;
|
|
}
|
|
else if (containers.Count == 1)
|
|
{
|
|
// Single-image shape used by the toolbar selected-object meters
|
|
// (health 0x100001A1, mana 0x100001A2).
|
|
// - The back-track sprite lives on the meter ELEMENT's own DirectState ("" key of
|
|
// info.StateMedia) — not on any grandchild image. e.g. health back = 0x0600193E.
|
|
// - The fill sprite lives on the single Type-3 child's own DirectState ("" key of
|
|
// containers[0].StateMedia). e.g. health fill = 0x0600193F.
|
|
// The fill child has NO image grandchildren, so SliceIds would return all-zero —
|
|
// read the container's StateMedia directly instead.
|
|
//
|
|
// These go in the TILE slot (not the left-cap slot): the sprites are DrawMode=Normal,
|
|
// which retail renders as "tile at native width to fill the full element geometry"
|
|
// (format doc §6; the generic UiDatElement.OnDraw Normal path; UIElement_Meter::
|
|
// DrawChildren :123574 clips the child's FULL 140px geometry box to the fill fraction).
|
|
// With the sprite on BackLeft instead, UiMeter.DrawHBar would clamp the cap to the
|
|
// sprite's NATIVE width (capL = min(nativeW, 140)) — leaving a right-side gap and
|
|
// mapping the fill fraction to native width when nativeW < 140. The tile slot makes
|
|
// midW = full bar width, so the back tiles across all 140px and the front clips to
|
|
// 140*fraction correctly for any native sprite width (left/right caps unused = 0).
|
|
// (retail: gmToolbarUI::HandleSelectionChanged :198635 / UIElement_Meter::DrawChildren :123574)
|
|
m.BackLeft = 0;
|
|
m.BackTile = info.StateMedia.TryGetValue("", out var bm) ? bm.File : 0u;
|
|
m.BackRight = 0;
|
|
|
|
m.FrontLeft = 0;
|
|
m.FrontTile = containers[0].StateMedia.TryGetValue("", out var fm) ? fm.File : 0u;
|
|
m.FrontRight = 0;
|
|
}
|
|
else
|
|
{
|
|
// Count == 0: no Type-3 containers at all — genuinely malformed meter dat.
|
|
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 1 or 2) — bars may render as solid-color fallback.");
|
|
}
|
|
|
|
return m;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the (left, tile, right) sprite ids for a 3-slice container,
|
|
/// extracting them from the container's image children that carry a DirectState
|
|
/// ("" key) with a non-zero file id, ordered left-to-right by their X position.
|
|
///
|
|
/// <para>
|
|
/// Children that carry ONLY named states (e.g. the expand-detail overlay with
|
|
/// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically
|
|
/// because <see cref="Dictionary{TKey,TValue}.TryGetValue"/> for "" returns
|
|
/// false.
|
|
/// </para>
|
|
/// </summary>
|
|
private static (uint left, uint tile, uint right) SliceIds(ElementInfo container)
|
|
{
|
|
// Only children that have a non-zero DirectState image are slice candidates.
|
|
// The expand-detail overlay has NO DirectState entry, so it's excluded here.
|
|
// Project the File during filtering to avoid a second TryGetValue lookup.
|
|
// Stable sort: on an X tie, original Children insertion order (dat key-sort order) wins.
|
|
var slices = container.Children
|
|
.Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0)
|
|
.Select(c => (c.X, File: c.StateMedia[""].File))
|
|
.OrderBy(t => t.X)
|
|
.ToList();
|
|
|
|
uint left = slices.Count > 0 ? slices[0].File : 0u;
|
|
uint tile = slices.Count > 1 ? slices[1].File : 0u;
|
|
uint right = slices.Count > 2 ? slices[2].File : 0u;
|
|
|
|
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.
|
|
///
|
|
/// <para>
|
|
/// Justification from the dat (<see cref="ElementInfo.HJustify"/> /
|
|
/// <see cref="ElementInfo.VJustify"/>) is applied here at build time so that controllers
|
|
/// that subsequently call <see cref="UiText.Centered"/> / <see cref="UiText.RightAligned"/>
|
|
/// on dat-origin elements can be simplified. Controllers that <em>explicitly</em> set those
|
|
/// properties after <see cref="ImportedLayout.FindElement"/> still override the build-time
|
|
/// defaults — the build-time value is just the starting point, not a lock.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="elementFont">The font to seed on the widget. When a font resolver was
|
|
/// provided and the element's FontDid resolved successfully, this is that element-specific
|
|
/// font; otherwise it is the shared global fallback. Controllers that call
|
|
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
|
|
/// still override this — the build-time value is just the starting point.</param>
|
|
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? elementFont = null)
|
|
{
|
|
uint bg = info.StateMedia.TryGetValue(
|
|
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
|
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
|
? m.File : 0u;
|
|
|
|
// Apply horizontal + vertical justification from the dat at build time.
|
|
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
|
|
// afterward will override these — this is only the dat-driven default.
|
|
bool centered = info.HJustify == HJustify.Center;
|
|
bool rightAligned = info.HJustify == HJustify.Right;
|
|
var vJustify = info.VJustify switch
|
|
{
|
|
VJustify.Top => VJustify.Top,
|
|
VJustify.Bottom => VJustify.Bottom,
|
|
_ => VJustify.Center,
|
|
};
|
|
|
|
var t = new UiText
|
|
{
|
|
ElementId = info.Id,
|
|
BackgroundSprite = bg,
|
|
SpriteResolve = resolve,
|
|
Centered = centered,
|
|
RightAligned = rightAligned,
|
|
VerticalJustify = vJustify,
|
|
// Seed the dat-driven font. When a font resolver was supplied and the element
|
|
// carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise
|
|
// it is the shared global fallback. Controllers that call FindElement and explicitly
|
|
// set DatFont afterward STILL override this (backward-compat guarantee).
|
|
DatFont = elementFont,
|
|
};
|
|
|
|
// Font color from dat property 0x1B (ColorBaseProperty).
|
|
// When present, seed DefaultColor so controllers that read it don't have to hard-code colors.
|
|
// Controllers that supply explicit per-line colors via LinesProvider still win — this is only
|
|
// the build-time default.
|
|
if (info.FontColor.HasValue)
|
|
t.DefaultColor = info.FontColor.Value;
|
|
|
|
return t;
|
|
}
|
|
}
|