acdream/docs/research/retail-ui/06-hud-and-assets.md
Erik 7230c1590f docs+feat(ui): retail UI deep-dive research + C# port scaffold
Deep investigation of the retail AC client's GUI subsystem, driven by 6
parallel Opus research agents, plus the first cut of a retail-faithful
retained-mode widget toolkit that scaffolds Phase D.

Research (docs/research/retail-ui/):
- 00-master-synthesis.md        — cross-slice synthesis + port plan
- 01-architecture-and-init.md   — WinMain, CreateMainWindow, frame loop,
                                  Keystone bring-up (7 globals mapped)
- 02-class-hierarchy.md         — key finding: UI lives in keystone.dll,
                                  not acclient.exe; CUIManager + CUIListener
                                  MI pattern, CFont + CSurface + CString
- 03-rendering.md               — 24-byte XYZRHW+UV verts, per-font
                                  256x256 atlas baked from RenderSurface,
                                  TEXTUREFACTOR coloring, DrawPrimitiveUP
- 04-input-events.md            — Win32 WndProc → Device (DAT_00837ff4)
                                  → widget OnEvent(+0x128); full event-type
                                  table (0x01 click, 0x07 tooltip ~1000ms,
                                  0x15 drag-begin, 0x21 enter, 0x3E drop)
- 05-panels.md                  — chat, attributes, skills, spells, paperdoll
                                  (25-slot layout), inventory, fellowship,
                                  allegiance — with wire-message bindings
- 06-hud-and-assets.md          — vital orbs (scissor fill), radar
                                  (0x06001388/0x06004CC1, 1.18× shrink),
                                  compass strip, dat asset catalog

Key insight: keystone.dll owns the actual widget toolkit — we cannot
port a class hierarchy from the decompile because it's not there.
Instead we implement our own retained-mode toolkit with retail-faithful
behavior (event codes, focus/modal/capture, drag-drop state machine)
and will consume the same portal.dat fonts + sprites so the visual
identity is preserved.

C# scaffold (src/AcDream.App/UI/):
- UiEvent          — 24-byte event struct + retail event-type constants
                     (0x01 click, 0x15 drag-begin, 0x201 WM_LBUTTONDOWN,
                     etc.) matching retail decompile switches
- UiElement        — base widget: children, ZOrder, focus/capture flags,
                     virtual OnDraw/OnEvent/OnHitTest/OnTick; children-
                     first hit test + back-to-front composite
- UiPanel          — panel, label, button primitives
- UiRenderContext  — 2D draw context with translate stack
- UiRoot           — top-of-tree + Device responsibilities (mouse/
                     keyboard state, focus, modal, capture, drag-drop,
                     tooltip timer); WorldMouseFallThrough/
                     WorldKeyFallThrough preserves existing camera
                     controls when no widget consumes
- UiHost           — packages UiRoot + TextRenderer + input wiring
                     helpers for one-line integration into GameWindow
- README.md        — orientation for future agents

Roadmap (docs/plans/2026-04-11-roadmap.md):
- D.1 marked shipped (debug overlay from 2026-04-17)
- D.2 expanded to include the retail UI framework landed here
- D.3-D.7 added: AcFont, dat sprites, core panels, HUD, CursorManager
- D.8 remains sound

All existing 470 tests pass. 0 warnings, 0 errors.
2026-04-17 19:13:02 +02:00

42 KiB
Raw Blame History

06 — HUD Elements and Dat-File UI Assets

Slice 6 of 6: Heads-Up Display + the catalog of every UI-related DBObj.

This document covers the always-on-screen HUD elements (vital orbs, radar, compass, quickbar, selection indicator, damage numbers, cursors, announcements, hover name) and maps the complete set of dat-file data-types used by the AC UI.

The retail client's in-game HUD is built on top of the same UI layer used by all dialogs and panels (see slices 0105). The defining property of the HUD is that it is always rendered last, over the 3D world, with transparency and the camera-cursor coupling that distinguishes "in-game" from "in a dialog".

Sources:

  • chunk_00400000.c options/settings UI wiring (tooltip, font-face, chat size). Every control references a LanguageString by name, routed through FUN_004016b0 (a localized-string lookup).
  • chunk_00410000.c the DBObj ID-range dispatcher (FUN_0041xxxx), which maps a 32-bit DataId prefix to an internal DBObjType constant. This is the ground truth for every range below.
  • chunk_00430000.c Win32 cursor plumbing (SetCursor, ShowCursor, SetClassLongA, HCURSOR ownership), plus BitBlt fallback paths for software rendering.
  • chunk_005A0000.c D3D render-state toggles including "RenderDeviceD3D.AllowDrawPrimUP" — the legacy 2D UI primitive-up path.
  • chunk_005C0000.c vital-name string resolution (Strength, Health, Maximum Stamina, …), used to populate tooltip text and attribute labels.
  • chunk_00680000.c cursor mode switching: SetCursorPos, ClientToScreen, ScreenToClient, mouse-look mode (cursor recenter) — the boundary between HUD-cursor and look-cursor states.
  • references/AC2D/cInterface.cpp + cCustomWindows.h — a contemporaneous C++ reimplementation of the AC UI with exact retail dat icon IDs baked into the code. Treated as reference for which assets are which.
  • references/DatReaderWriter/ — the canonical C# model of every portal and local DBObj with ID ranges from the generator XML.

Related retail / cross-check:

  • references/Chorizite.ACProtocol/protocol.xml the authoritative VitalId (0x01, 0x03, 0x05) and CurVitalId (0x02, 0x04, 0x06) enums plus the SecondaryAttributeInfo packet shape.
  • references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePublicUpdateVital.cs server side of the vital-update path.
  • references/holtburger/apps/holtburger-cli/src/pages/game/hud/ a working Rust client's HUD data model (what it actually pulls from game state).

Part A — HUD Elements

AC's HUD is composed of a fixed set of movable, transparent windows stacked on top of the 3D render. In retail they are: the three vital orbs (health/stamina/mana), the radar dish, the compass arc, the hotbar, the chat panel, the selection/target name plate, announcement strip, 3D world hover name, damage floaters, and the status icon strip (combat mode, "connection good" indicator, encumbrance state).

The underlying "movable window" UI is driven by the ElementDesc / StateDesc / LayoutDesc data described in slices 0104. Each HUD element has a LayoutDesc in the 0x21xxxxxx range and swaps its child Sprite IDs (0x06xxxxxx) through UIStateId transitions.

A.1 Health / Stamina / Mana globes

Purpose: bottom-left (or bottom-center, depending on the UI profile) three vertical orbs that show the fraction current / max for each vital. Clicking one opens the vital's detail panel; hovering shows the numeric current/max and rate-of-regen tooltip.

Data sources (from protocol.xml):

Enum Value Meaning
VitalId.MaximumHealth 0x01 Max HP (from Qualities_UpdateAttribute2nd)
CurVitalId.CurrentHealth 0x02 Current HP (from PrivateUpdateAttribute2nd)
VitalId.MaximumStamina 0x03 Max stamina
CurVitalId.CurrentStamina 0x04 Current stamina
VitalId.MaximumMana 0x05 Max mana
CurVitalId.CurrentMana 0x06 Current mana

Each vital arrives as a SecondaryAttributeInfo structure:

SecondaryAttributeInfo {
    AttributeInfo Attribute;  // ranks, base, investment
    uint Current;             // current value
}

The client keeps a local cCharInfo store (see AC2D's cCharInfo.cpp for naming). The orb renderer subscribes to changes and recomputes a fill fraction f = min(1, current / buffed_max).

Rendering — how the partial fill is drawn

Retail uses a textured quad with a clip rectangle (scissor rect), not a colored gradient. AC2D's cProgressBar (which cVitalsWindow uses) implements the pattern:

  1. Draw the full "empty" background sprite (the globe outline, fixed image).
  2. Set a glScissor (or a D3D8 sub-rect when bitblitting) that covers only the bottom f * height pixels of the orb.
  3. Draw the "full" colored sprite clipped to the scissor.
  4. Restore scissor.

Retail's exact IDs (per AC2D's cInterface.cpp::cInterface()):

  • 0x060013B2 — icon for the "Vitals" window titlebar.
  • Interior globe colors:
    • Blue 0x0000FF (mana)
    • Cyan 0x10F0F0 (stamina; AC2D uses this, retail appears similar)
    • Red 0xFF0000 (health)

In retail the orbs are visually three-dimensional (specular highlight + shaped alpha). They are not procedural fills — they are pre-rendered RGBA sprites loaded from RenderSurface (0x06xxxxxx) through SurfaceTexture (0x05xxxxxx).

Text overlay. A Font (0x400000000x40000FFF) renders the numeric value centered over the orb when the mouse hovers (or when the user enables "always show values" in settings).

Render-path pseudocode:

function DrawVitalOrb(vital_type, current, buffed_max, x, y, w, h):
    sprite_empty  = GetOrbSpriteForVital(vital_type, state = Empty)
    sprite_filled = GetOrbSpriteForVital(vital_type, state = Filled)

    DrawSprite(sprite_empty, x, y, w, h)

    fill_fraction = clamp(current / buffed_max, 0, 1)
    fill_px       = round(fill_fraction * h)

    PushScissor(x, y + h - fill_px, w, fill_px)
    DrawSprite(sprite_filled, x, y, w, h)
    PopScissor()

    if mouse_over or always_show:
        text = format("{0}/{1}", current, buffed_max)
        DrawTextCentered(font = GetUIFont(), text, x + w/2, y + h/2, color = #FFFFFF)

C# port sketch:

public sealed class VitalOrb
{
    readonly uint _spriteFrameId;    // 0x06xxxxxx
    readonly uint _spriteFilledId;   // 0x06xxxxxx
    readonly VitalKind _kind;

    public void Draw(IUiRenderer r, Rect bounds, uint current, uint buffedMax)
    {
        r.DrawSprite(_spriteFrameId, bounds);

        var fill = buffedMax == 0 ? 0f : MathF.Min(1f, (float)current / buffedMax);
        var fillPx = (int)MathF.Round(fill * bounds.Height);
        var clipRect = new Rect(bounds.X, bounds.Y + bounds.Height - fillPx, bounds.Width, fillPx);

        using (r.PushScissor(clipRect))
            r.DrawSprite(_spriteFilledId, bounds);

        if (r.HoverContains(bounds))
            r.DrawText(_uiFont, $"{current}/{buffedMax}",
                       bounds.Center, Color.White, TextAlign.Center);
    }
}

A.2 Radar / compass

The radar is the canonical "small circular polar plot of nearby creatures with the player at the center". The compass is a thin bar across the top of the screen showing the 16 cardinal directions as the camera rotates.

Radar — data sources

  • Player world position (Position packet, 24-byte LandCell + XYZ).
  • Every nearby object's CreateObject / UpdatePosition with heading.
  • Per-object RadarColor override (hostile = red, green = friendly NPC, etc.) + ObjectFlags2 bits (0x08 = item, 0x10 = blue-book NPC).

Radar — retail dat IDs (AC2D, cInterface.cpp:139-144):

  • 0x06001388 — radar window titlebar/toolbar icon.
  • 0x06004CC1 — radar background art (the circular bezel).

Radar — the player arrow + blip placement

AC2D cRadar::OnRender (cCustomWindows.h:1004-1070) is the clearest retail-equivalent. The math:

for each nearby object obj:
    delta      = obj.pos - player.pos
    delta      = delta.RotateAround(Z, -player.heading)   // align to radar-up = camera-forward
    screen.x   = radar.left   + radar.w/2 + (delta.x / (1.18 * range)) * (radar.w/2)
    screen.y   = radar.top    + radar.h/2 - (delta.y / (1.18 * range)) * (radar.h/2)
    color      = PickRadarColor(obj.radar_override, obj.flags2)
    DrawQuad2x2(screen, color)

The 1.18 factor is retail-observed — it shrinks the effective range slightly so blips near the edge stay visible before the bezel clips them. Player's own arrow is NOT in the blip loop; it is drawn as a fixed centered sprite (the "player dot") rotated by player.heading.

Compass — data sources

  • player.heading in radians.

Compass — how the rose is drawn

The rose is a seamless horizontal strip texture where 360° is tiled across some multiple of the screen width. The U-offset is heading_normalized * strip_u_period, with the visible portion cropped to a narrow strip at the top of the screen. This is the classic "scrolling texture" approach used by most 3D clients; retail AC follows it.

Holtburger's TUI compass (hud/status.rs:11-18) enumerates the 16 cardinal-direction labels that retail paints onto the strip:

["W", "WNW", "NW", "NNW", "N", "NNE", "NE", "ENE",
 "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW"]

22.5° per segment, first label centered on 11.25°.

Render-path pseudocode:

function DrawCompassStrip(heading_rad, bar_x, bar_y, bar_w, bar_h):
    heading_deg = (heading_rad * 180 / PI) mod 360
    // Texture is 360° wide in world-space; we crop to bar_w centered on heading
    u_center  = heading_deg / 360           // 0..1
    u_half    = bar_w / strip_texture_w / 2
    u_left    = u_center - u_half
    u_right   = u_center + u_half
    DrawSpriteUV(compass_strip_tex, bar_x, bar_y, bar_w, bar_h,
                 u0=u_left, u1=u_right, v0=0, v1=1)

C# port sketch:

public void DrawRadar(IUiRenderer r, Rect bounds, float playerHeading,
                      Vector3 playerPos, IEnumerable<WorldEntity> nearby, float range)
{
    r.DrawSprite(/*0x06004CC1*/ _radarBgId, bounds);

    var cx = bounds.X + bounds.Width  * 0.5f;
    var cy = bounds.Y + bounds.Height * 0.5f;

    foreach (var e in nearby)
    {
        var d = e.Position - playerPos;
        d    = Vector3.Transform(d, Matrix4x4.CreateRotationZ(-playerHeading));
        var sx = cx + (d.X / (1.18f * range)) * (bounds.Width  * 0.5f);
        var sy = cy - (d.Y / (1.18f * range)) * (bounds.Height * 0.5f);
        var col = PickRadarColor(e);
        r.DrawFilledQuad(sx - 1, sy - 1, 2, 2, col);
    }

    // Player arrow - always on top
    r.DrawSprite(_playerArrowId, cx - 5, cy - 5, 10, 10, rotation: playerHeading);
}

A.3 Quickbar / hotbar

The hotbar is the horizontal strip of spell-and-item slots (traditionally 7 main bars with 812 slots each) at the bottom of the screen.

Data: each slot holds either:

  • An ObjectId (for items: a potion, a healer kit), OR
  • A spell uint (for spells).

The client persists the mapping in acclient.cfg plus server-side character options.

Dat IDs in retail (AC2D cCustomWindows.h:395-509):

  • 0x060011D2 — selection highlight ring (drawn under selected slot).
  • 0x06001AB0 / 0x06001AB2 — spell-bar tab in "unselected" / "selected" state.
  • Spell-level icons (7 tiers): 0x060013F4, 0x060013F5, 0x060013F6, 0x060013F7, 0x060013F8, 0x060013F9, 0x06001F63.
  • Slot background 0x06001AB2 (48px wide slot frame).

Slot interaction:

  1. Drag from inventory → hover over slot → UIStateId.Drag_rollover_accept (0x09) if droppable, Drag_rollover_reject (0x0A) if not. This is a per-slot StateDesc transition in the LayoutDesc.
  2. Drop: ItemSlot_DragOver_DropIn (0x10000046) fires, followed by ItemSlot_Filled (0x1000001D).
  3. Click: the slot's icon is looked up, and the client dispatches either:
    • C2S_UseItem if it's an object, or
    • C2S_CastSpell if it's a spell.
  4. Keybind: F1-F12 and 1-0 map to slot indices via MasterInputMap (0x14000000-0x1400FFFF).

Per-slot draw order:

for each slot:
    DrawSprite(slot.bg, rect)              # 0x06001AB2 (frame)
    if slot.occupant:
        DrawSprite(slot.occupant.icon, rect)  # the item's 0x06xxxxxx icon
        if slot.occupant is Spell:
            DrawSprite(spell_tier_icon[slot.occupant.level-1], corner_rect)
    if slot.is_focused:
        DrawSprite(0x060011D2, rect)       # selection ring
    if slot.keybind:
        DrawText(ui_font, slot.keybind_label, corner)

A.4 Selection target indicator

When the player clicks a creature or another player, the client draws:

  1. A floating name plate above their head in 3D space (billboarded).
  2. An over-the-head health bar showing current_health / max_health of the selected target.
  3. Below the name plate: extra text for target state (e.g. "Selected", "Talking to", monster level if allowed).

Data sources:

  • selected_object_id local UI state.
  • The target's CreateObject gave the client its name, level, and visible health fraction (NOT the exact current HP — servers usually obfuscate that).
  • Qualities_UpdateAttribute2nd for the target's vitals (when selected; the server streams public vital updates for the selected target).

Retail UIStateId transitions:

  • UIStateId.ObjectSelected (0x1000000B) — when a world object becomes the primary selection.
  • UIStateId.Unselected / Selected (0x10000016 / 0x10000017) — for UI chip and menu items referencing the same object.

Render path:

function DrawSelectionHealthBar(target, world_to_screen, camera):
    if target == null: return

    head_world = target.pos + (0, 0, target.height + 0.2)
    head_screen = world_to_screen(head_world)
    if head_screen.z < 0 or head_screen.off_screen: return

    // Fixed 96x8 bar
    bar_x = head_screen.x - 48
    bar_y = head_screen.y - 24

    // Full bar background
    DrawFilledRect(bar_x, bar_y, 96, 8, color = #40202020)

    // Health fill
    frac = clamp(target.health_pct, 0, 1)
    fill_w = round(96 * frac)
    fill_color = health_color_for_fraction(frac)  // green→yellow→red
    DrawFilledRect(bar_x, bar_y, fill_w, 8, fill_color)

    // Name below the bar
    DrawTextCentered(ui_font, target.display_name,
                     head_screen.x, bar_y + 12, target.name_color)

health_color_for_fraction: retail interpolates linearly between #00FF00 (100%) → #FFFF00 (50%) → #FF0000 (0%).

A.5 Damage numbers (floating text)

When the player or the currently-selected target takes damage, retail shows a short-lived floating text at the head of the hit target: red for damage, yellow-green for heal.

Data source:

  • GameEventCombatDamage (AC GameEventType.CombatDamage = 0x01AE). The packet carries attacker_id, victim_id, damage amount, damage type, and location.
  • Derived client-side: the client decides whose head to anchor to.

Lifecycle per floater:

FloatingNumber {
    world_anchor: Vector3,
    offset_y: float,       // starts at 0, grows over life
    alpha: float,          // 1 → 0 over life
    life_remaining: float, // typically 1.5s
    text: string,          // "127" or "Heal 40"
    color: RGBA
}

Update:

per frame:
    for each f in floaters:
        f.offset_y += 40 * dt            # pixels per second, upward
        f.life_remaining -= dt
        f.alpha = clamp(f.life_remaining / 0.4, 0, 1)
        if f.life_remaining <= 0: remove

    for each f:
        head_screen = world_to_screen(f.world_anchor)
        DrawTextOutlined(font, f.text,
                         head_screen.x, head_screen.y - f.offset_y,
                         f.color with alpha = f.alpha,
                         outline = black)

Retail does the outline as a 4-corner black stamp then white fill, not SDF — the pixel-perfect AC font system pre-dates SDF.

A.6 Cursor customization

Decompiled evidence (chunk_00430000.c:7854-8024):

// FUN_00439320: restore default system cursor
HCURSOR hCursor = LoadCursorA(0, (LPCSTR)0x7f00);   // IDC_ARROW
SetCursor(hCursor);

// FUN_004395d0: install a custom cursor on the window class
HICON hIcon = (HICON)GetClassLongA(hwnd, -0xc /* GCL_HCURSOR */);
if (hIcon != param_1) DestroyIcon(hIcon);
SetClassLongA(hwnd, -0xc, (LONG)param_1);
SetCursor(param_1);

Retail uses Win32 cursors for the base arrow and custom-shaped cursors for contextual modes. The cursor shape is driven by the HUD hit-test of the current frame: moving over an NPC yields the "talk" cursor, over a monster with the melee cursor, over inventory with the "drag" cursor.

Cursor asset source (dat): retail stores custom cursors as MediaDescCursor sub-records inside StateDesc.Media for specific UI states. Per MediaDescCursor.generated.cs:

public class MediaDescCursor : MediaDesc {
    public uint File;        // underlying SurfaceTexture (0x05xxxxxx)
    public uint XHotspot;    // pixels from image top-left
    public uint YHotspot;
}

The client converts these into Win32 HCURSOR at load time (CreateIconIndirect on a monochrome mask + color bitmap pair), then swaps them via SetCursor(hcursor) as the UI state transitions happen.

Mouse-look mode (chunk_00680000.c:9789-9804) reveals the cursor-recenter pattern retail uses to implement "right-click-hold to mouse-look":

// Pseudocode of FUN_0068a930 mouse-look
center_x = client_width / 2;
center_y = client_height / 2;
client_to_screen(hwnd, &center_x, &center_y);
SetCursorPos(center_x, center_y);

Each frame, mouse delta = actual_pos - last_center; then snap back to center. The HUD cursor sprite is hidden during this (ShowCursor(FALSE)).

UIStateId cursor contexts (from UIStateId.generated.cs):

State Use
Drag_rollover_accept green-tinted drag cursor
Drag_rollover_reject red X cursor
ObjectSelected selection pointer
JumpMode jump cursor (space held)
MeleeMode combat crosshair
MissileMode ranged combat crosshair
DDDMode dialog-pointing cursor
Csm_highlight / Csm_normal / Csm_ghosted context-sensitive movement cursor

A.7 Announcement / status bar

The announcement strip appears near the top-center when "big" events happen: death messages, level-up, server-wide broadcasts ("Server will shut down in 15 minutes"), and the MOTD on first login.

Data source: GameEventEvent with subtype SystemBroadcast / AdminBroadcast / CombatDeath, plus the server's GameMessageMOTD on entering-the-world.

Visible lifecycle:

  1. Fade in over 0.3s (alpha 0 → 1).
  2. Hold for message_duration (default 5s, scales with message length).
  3. Fade out over 0.5s.

The strip is a single horizontal LayoutDesc-backed panel; it loads the bordered-panel background sprite and lays one or two cStaticText-style children centered.

Retail dat ID: the connecting/MOTD panel uses 0x06004CB2 (AC2D cInterface.cpp:196) as the "Enter Game" button/panel art. Most announcement strips use the same bordered-panel sprite family.

A.8 3D item hover name

When the mouse hovers over a world object (without clicking), a floating text tag appears above the object: the object's display name, colored by faction/allegiance.

Hit test: the client does a picking ray from the screen cursor, runs it through the physics BSP (see src/AcDream.Core/Physics/BSPQuery.cs), finds the hit object, and checks its ObjectDescriptionFlag to decide whether hover names should be shown. The "Maximum Tooltip Distance" setting (chunk_00400000.c, around the ID_Misc_TooltipDelay binding) gates the display.

Render:

function DrawHoverName(target, world_to_screen):
    if target == null: return
    if distance(player, target) > hover_max_range: return

    head = target.pos + (0, 0, target.height + 0.15)
    ss = world_to_screen(head)
    if ss.z < 0 or off_screen: return

    text = target.display_name
    color = hover_color_for_relation(target)
    size = measure_text(font, text)

    DrawFilledRect(ss.x - size.x/2 - 3, ss.y - size.y - 2,
                   size.x + 6, size.y + 4, color = #C0000000)
    DrawTextCentered(font, text, ss.x, ss.y - size.y, color)

The size + 6 padding is for the pill-shaped background. ss.y - size.y stacks the name above the object's head; the health bar (if target is selected) sits below.


Part B — Dat-File UI Assets

This is the catalog of every DBObj type used by the UI subsystem, derived from references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/*.cs and verified against the decompiled ID-range dispatcher in chunk_00410000.c.

B.1 Master ID-range table

The retail client's FUN_0041ccc0 dispatcher (which returns a DBObjType-equivalent integer from a DataId prefix) confirms the following ranges. Every table entry is load-bearing — do not paraphrase.

DataId prefix Name DBObjType Dat file Notes
0x01xxxxxx GfxObj 2 Portal 3D mesh/geometry (not UI)
0x02xxxxxx Setup 3 Portal Multi-part rig (not UI)
0x03xxxxxx Animation 4 Portal Keyframed anim (not UI)
0x04xxxxxx Palette 5 Portal ARGB lookup; UI tinted sprites
0x05xxxxxx SurfaceTexture 6 Portal Mip chain of RenderSurface
0x06xxxxxx RenderSurface (Icon) 7 Portal THE UI icon/sprite space
0x07xxxxxx RenderSurface 7 Portal HiRes dat overflow for icons
0x08xxxxxx Surface 8 Portal Material pointing at tex+palette
0x09xxxxxx MotionTable 9 Portal (not UI)
0x0Axxxxxx Wave 10 Portal Audio (not UI but UI plays them)
0x0Dxxxxxx Environment 11 Portal (not UI)
0x0Exxxxxx Table singletons mixed Portal CharGen, ChatPoseTable, etc.
0x0Fxxxxxx PaletteSet / PalSet 17 Portal Subpalette swaps for heritage tinting
0x10xxxxxx Clothing / ClothingTable 18 Portal (not UI directly)
0x11xxxxxx GfxObjDegradeInfo 19 Portal (not UI)
0x12xxxxxx Scene 20 Portal (not UI)
0x13xxxxxx Region 21 Portal Skybox — UI reads daylight from it
0x14xxxxxx MasterInputMap 22 Portal UI keybinding map
0x15xxxxxx RenderTexture 23 Portal Material-system texture (not UI)
0x16xxxxxx RenderMaterial 24 Portal Material system (not UI)
0x17xxxxxx MaterialModifier 25 Portal
0x18xxxxxx MaterialInstance 26 Portal
0x20xxxxxx SoundTable 27 Portal UI sounds (click, error)
0x21xxxxxx LayoutDesc 47 Local THE UI LAYOUT FILE
0x22xxxxxx EnumMapper 28 Portal Id→string helpers
0x23xxxxxx / 0x24xxxxxx StringTable 48 Local Localized strings for UI
0x25xxxxxx EnumIDMap 29 Portal
0x27xxxxxx DualEnumIDMap / DualDataIdMapper 32 Portal
0x30xxxxxx CombatTable 34 Portal
0x31xxxxxx LanguageString 33 Portal Loose localized string
0x32xxxxxx ParticleEmitter(Info) 35 Portal Spell FX particles (seen in UI previews)
0x33xxxxxx PhysicsScript 36 Portal
0x34xxxxxx PhysicsScriptTable 37 Portal
0x400000000x40000FFF Font 38 Portal BITMAP FONTS for the UI
0x41xxxxxx LanguageInfo 49 Local IME + text formatting config

Legend: bolded rows are what a UI layer needs.

B.2 UI-critical DBObj types in depth

Font (0x40000000 0x40000FFF)

public class Font : DBObj {
    public uint MaxCharHeight;
    public uint MaxCharWidth;
    public List<FontCharDesc> CharDescs;
    public uint NumHorizontalBorderPixels;
    public uint NumVerticalBorderPixels;
    public uint BaselineOffset;
    public uint ForegroundSurfaceDataId;    // -> 0x06xxxxxx RenderSurface
    public uint BackgroundSurfaceDataId;    // -> 0x06xxxxxx RenderSurface (outline/shadow)
}

public class FontCharDesc {
    public ushort Unicode;                  // codepoint
    public ushort OffsetX, OffsetY;         // position within atlas image
    public byte   Width, Height;            // glyph bbox
    public sbyte  HorizontalOffsetBefore;   // pre-advance (kerning-ish)
    public sbyte  HorizontalOffsetAfter;    // post-advance
    public sbyte  VerticalOffsetBefore;     // baseline adjust
}

Retail has two surfaces per font: ForegroundSurfaceDataId (the glyph pixels, typically white A8) and BackgroundSurfaceDataId (an outline stroke). The renderer blits background first with the text color shifted darker, then foreground with the fill color, giving AC's characteristic outlined UI text.

Glyph lookup is linear in CharDescs, sorted by Unicode. Retail does a binary search. For modern C# the port should build a Dictionary<int, FontCharDesc> at load time.

Typical font IDs (observed in retail settings dialog via chunk_00400000.c::FUN_004037b0): the chat font is chosen from a fixed list hardcoded by face name — "Arial", "CourierNew", "PalatinoLinotype", "Tahoma", "TimesNewRoman". Each face maps to a Font DataId at runtime via a mapping stored in StringTable.

LayoutDesc (0x21000000 0x21FFFFFF, Local dat)

This is the single most important UI dat type. Every HUD panel, every dialog, every chat window layout, is a LayoutDesc. The file resides in the Local dat (client_local_English.dat), not the Portal dat.

public class LayoutDesc : DBObj {
    public uint Width;
    public uint Height;
    public HashTable<uint, ElementDesc> Elements;   // top-level elements by ElementId
}

public class ElementDesc {
    public StateDesc StateDesc;                      // default state
    public uint ReadOrder;                           // render order within parent
    public uint ElementId;                           // unique per LayoutDesc
    public uint Type;                                // element type (button/text/panel/etc.)
    public uint BaseElement;                         // inheritance ref
    public uint BaseLayoutId;                        // parent LayoutDesc for inheritance
    public UIStateId DefaultState;
    public uint X, Y;                                // relative to parent
    public uint Width, Height;
    public uint ZLevel;
    public uint LeftEdge, TopEdge, RightEdge, BottomEdge;  // anchor margins
    public Dictionary<UIStateId, StateDesc> States;  // per-state visuals
    public Dictionary<uint, ElementDesc> Children;
}

public class StateDesc {
    public uint StateId;
    public bool PassToChildren;
    public IncorporationFlags IncorporationFlags;   // which fields the child overrides
    public Dictionary<uint, BaseProperty> Properties;
    public List<MediaDesc> Media;                   // Image/Cursor/Sound/Anim per state
}

An ElementDesc has a default StateDesc + a dictionary of per-state StateDescs. When the UI's logical state changes (e.g. mouse enters a button → UIStateId.Normal_rollover), the renderer looks up the matching StateDesc and re-reads Properties + Media. This is how buttons change their sprite on hover, how vital orbs switch color when the player is poisoned, and how the drag-drop target shows a green/red highlight.

The IncorporationFlags enum determines whether the state override applies X, Y, Width, Height, ZLevel etc. Most per-state overrides only change Media (the sprite behind the element), not geometry.

ElementDesc.Type is an integer code. From protocol-adjacent evidence and ACViewer naming, the known values are:

  • 0 = Root (panel)
  • 1 = Picture (sprite blitter)
  • 2 = Text (string label)
  • 3 = Button (picture + label + state)
  • 4 = EditBox
  • 5 = ScrollBar
  • 6 = ListBox
  • ...

These are the primitive widget kinds the UI renderer dispatches on.

StringTable (0x23000000 0x24FFFFFF, Local dat)

public class StringTable : DBObj {
    public uint Language;    // 1 = English
    public HashTable<uint, StringTableString> Strings;
}

Every localized UI string lives here, keyed by a stable internal ID. The decompiled settings-UI wiring in chunk_00400000.c shows the retail access pattern:

// FUN_004037b0 (the settings dialog construction)
uVar2 = FUN_004016b0("ID_Sound_DisableSound_Help");        // look up help text
uVar2 = FUN_004016b0("ID_Sound_DisableSound", uVar2);      // bind label + help
FUN_005dee50(&DAT_008375b0, 4, 0x10000003, uVar2);         // install into control

FUN_004016b0 is the StringTable lookup by name-hash. It returns a StringId that points into the current StringTable. When the user switches language, a different StringTable is loaded but the same hash still resolves.

Retail ID prefixes observed:

Prefix Domain
ID_UI_* UI widget labels
ID_Misc_* misc UI (tooltips)
ID_Sound_* audio settings
ID_Graphics_* graphics settings
ID_Chat_* chat system
ID_Inventory_* inventory panel

The hash function (from ACE's DatLoader) is a variant of Pearson hashing over the lowercased byte string. Port this exactly; computed IDs are compared against dat values.

LanguageString (0x31000000 0x3100FFFF, Portal dat)

public class LanguageString : DBObj {
    public PStringBase<byte> Value;    // raw ASCII/UTF-8 string
}

A free-standing single string, separate from StringTable. Retail uses this for very large strings or for strings that don't belong to a table (help text, tutorial content, quest descriptions shown in panels).

Palette (0x04000000 0x0400FFFF)

public class Palette : DBObj {
    public List<ColorARGB> Colors;   // up to 256 entries
}

Paired with RenderSurface.Format = PFID_INDEX16 | PFID_P8. UI uses this to tint a single icon by swapping palettes — e.g. heritage-colored UI buttons in character creation cycle between Aluvian / Gharundim / Sho palettes.

PaletteSet / PalSet (0x0F000000 0x0F00FFFF)

public class PaletteSet : DBObj { public List<uint> Palettes; }

A list of Palette DataIds. Used when a UI element can be in multiple tinted variants (e.g. the "connection quality" traffic light cycling between good/uncertain/bad by swapping the active palette index).

RenderSurface (0x06000000 0x07FFFFFF)

public class RenderSurface : DBObj {
    public int Width, Height;
    public PixelFormat Format;
    public byte[] SourceData;
    public uint DefaultPaletteId;   // only if Format is INDEX16 or P8
}

This is the bytes behind every UI icon in the game. A few properties matter for UI:

  • Format = PFID_INDEX16 — 16-bit indexed, paired with DefaultPaletteId. Typical for tintable icons.
  • Format = PFID_A8R8G8B8 — 32-bit ARGB. Direct blit, premultiplied if the owning Surface has nonzero Translucency.
  • Format = PFID_CUSTOM_RAW_JPEG — compressed raw JPEG bytes. Retail's portrait-rendered images (e.g. splash art).
  • Format = PFID_DXT1/PFID_DXT5 — compressed. Usually world textures but occasionally used for high-res UI backdrops.

Width/Height for UI icons are predominantly:

  • 16×16 (hotbar slot icon, small skill icon)
  • 32×32 (inventory icon)
  • 64×64 (spell book icon)
  • Various (panel background sprites, titlebar icons, 9-slice edges)

SurfaceTexture (0x05000000 0x05FFFFFF)

public class SurfaceTexture : DBObj {
    public TextureType Type;
    public List<QualifiedDataId<RenderSurface>> Textures;  // mip chain
}

Wraps one or more RenderSurface into a mipmapped texture. UI usually uses only mip 0 (no mips for 2D pixels), but the wrapper is still required.

Surface (0x08000000 0x0800FFFF)

public class Surface : DBObj {
    public SurfaceType Type;                               // bitfield
    public QualifiedDataId<SurfaceTexture> OrigTextureId;  // set if Base1Image or Base1ClipMap
    public QualifiedDataId<Palette> OrigPaletteId;
    public ColorARGB ColorValue;                           // set if no texture
    public float Translucency;
    public float Luminosity;
    public float Diffuse;
}

A Surface is a material descriptor: "use this SurfaceTexture with this Palette, at this translucency, with this luminosity glow". UI-side, Translucency controls the window transparency setting the player picks in AC ("move the slider to make chat window 60% opaque" sets the Translucency on the chat LayoutDesc's backing Surface).

MasterInputMap (0x14000000 0x1400FFFF)

Stores the keybinding map: "F1 → action Cast", "I → open inventory", etc. Port this verbatim; it's a lookup table the UI event handler walks on every key press.

SoundTable (0x20000000 0x2000FFFF)

UI sound cues (click, error, level-up, unlock) live here. Not strictly "visual UI" but the HUD dispatches them alongside draw calls. See MediaDescSound inside StateDesc.Media for the per-state sound triggers.

B.3 How the client chooses which dat asset to load

For a given HUD element the data-flow is:

  1. UI layer starts with a known LayoutDesc DataId. For example the "main game HUD" layout is a fixed DataId the client knows at compile time — it's a 0x21xxxxxx constant baked in.
  2. LayoutDesc.Elements iterates children. Each ElementDesc's default StateDesc.Media list contains a MediaDescImage or MediaDescCursor — these hold a File field which is a 0x06xxxxxx RenderSurface DataId (icon), a 0x08xxxxxx Surface DataId (material), or similar.
  3. State transitions swap media. When UIStateId changes on an element, the client re-reads the per-state StateDesc, pulls its Media list, and re-binds sprites.
  4. Text is resolved via hash. Every text label's string-content isn't stored in the layout; the layout stores a StringId (hash), which is passed to FUN_004016b0 (StringTable.Lookup(id)) and returns the string pointer in the currently-active StringTable.
  5. Icons-from-server vs icons-from-dats. Most HUD icons are hardcoded dat IDs (see AC2D's table). But inventory icons and spell icons are data-driven: each WorldObject's CreateObject packet carries its IconId (0x06xxxxxx) from the server, which the client blits at its designated slot. This is the one case where server drives icon choice; for the HUD chrome itself (frame art, globe art, compass strip, selection ring) the IDs are fixed.

B.4 Pseudocode: loading a UI icon and blitting it

This is the reference flow for "take a 0x06xxxxxx DataId and draw it into a rect on screen". Follow it exactly when porting.

function DrawUIIcon(iconDataId, rect):
    assert (iconDataId & 0xFF000000) in {0x06000000, 0x07000000}

    // 1. Resolve RenderSurface
    rs = PortalDat.Read<RenderSurface>(iconDataId)
    if rs == null:
        rs = HighResDat.Read<RenderSurface>(iconDataId)   // optional fallback
    if rs == null: return Error

    // 2. Decode pixels into a GPU-uploadable byte[] (BGRA8)
    pixels = DecodeToBGRA8(rs, rs.Format, rs.SourceData, rs.DefaultPaletteId)

    // 3. Upload (or reuse from cache)
    tex = TextureCache.GetOrUpload(iconDataId, rs.Width, rs.Height, pixels)

    // 4. Draw textured quad at rect, with straight alpha-blending
    PushBlendMode(AlphaBlend)
    DrawQuad(tex, rect, uv = (0, 0, 1, 1), tint = White)
    PopBlendMode()

function DecodeToBGRA8(rs, format, raw, defaultPaletteId):
    switch format:
        case PFID_A8R8G8B8:
            out = copy raw
            SwapRedAndBlue(out)                # AC stores ARGB, GL wants BGRA
            return out
        case PFID_R5G6B5:
            return Expand565ToBGRA(raw)
        case PFID_A4R4G4B4:
            return Expand4444ToBGRA(raw)
        case PFID_INDEX16:
            pal = PortalDat.Read<Palette>(defaultPaletteId).Colors
            out = new byte[rs.Width * rs.Height * 4]
            for each 16-bit index i in raw:
                color = pal[i & 0x7FF]         # 11-bit index; high bits mean 'paletted'
                out.AppendBGRA(color)
            return out
        case PFID_P8:
            pal = PortalDat.Read<Palette>(defaultPaletteId).Colors
            out = new byte[rs.Width * rs.Height * 4]
            for each 8-bit index i in raw:
                out.AppendBGRA(pal[i])
            return out
        case PFID_DXT1, PFID_DXT3, PFID_DXT5:
            return DecodeDXT(raw, format, rs.Width, rs.Height)
        case PFID_A8:
            # grayscale alpha - expand with white RGB
            return ExpandA8ToBGRA(raw, fill = #FFFFFF)
        case PFID_CUSTOM_RAW_JPEG:
            bmp = DecodeJPEG(raw)
            return BitmapToBGRA(bmp)
        default:
            return Error("unsupported format 0x%X" % format)

Cache key: (iconDataId, paletteOverrideId). Palette overrides are needed when a UI subsystem wants to tint an indexed icon differently (e.g. the same base sprite shown in green for "trained" and red for "specialized" skills — retail uses Palette overrides, not color-mul).

C# port sketch (Silk.NET + .NET 10):

public sealed class UiIconCache
{
    readonly PortalDat _portal;
    readonly PortalDat? _highRes;
    readonly GL _gl;
    readonly Dictionary<(uint Id, uint Palette), GpuTexture> _cache = new();

    public GpuTexture Load(uint iconId, uint paletteOverride = 0)
    {
        var key = (iconId, paletteOverride);
        if (_cache.TryGetValue(key, out var tex)) return tex;

        var rs = _portal.Read<RenderSurface>(iconId)
              ?? _highRes?.Read<RenderSurface>(iconId)
              ?? throw new UiAssetMissing(iconId);

        var paletteId = paletteOverride != 0 ? paletteOverride : rs.DefaultPaletteId;
        var pixels = DecodeToBgra8(rs, paletteId);

        tex = GpuTexture.Upload(_gl, rs.Width, rs.Height, pixels);
        _cache[key] = tex;
        return tex;
    }

    byte[] DecodeToBgra8(RenderSurface rs, uint paletteId)
        => rs.Format switch
        {
            PixelFormat.PFID_A8R8G8B8       => SwapArgbToBgra(rs.SourceData),
            PixelFormat.PFID_R5G6B5         => Expand565(rs.SourceData),
            PixelFormat.PFID_A4R4G4B4       => Expand4444(rs.SourceData),
            PixelFormat.PFID_INDEX16        => ExpandIndex16(rs, _portal.Read<Palette>(paletteId)!),
            PixelFormat.PFID_P8             => ExpandP8(rs, _portal.Read<Palette>(paletteId)!),
            PixelFormat.PFID_DXT1
              or PixelFormat.PFID_DXT3
              or PixelFormat.PFID_DXT5      => DxtDecoder.Decode(rs),
            PixelFormat.PFID_A8             => ExpandA8(rs.SourceData),
            PixelFormat.PFID_CUSTOM_RAW_JPEG => JpegToBgra(rs.SourceData),
            _ => throw new NotSupportedException($"UI format {rs.Format}")
        };
}

B.5 Known hardcoded HUD dat IDs (from AC2D)

These are the specific DataIds AC2D hardcodes for each HUD window, copied verbatim from the retail .dat files. Use them as golden values for conformance tests — if acdream can load these IDs and render them, the HUD is wired.

Element IconId (titlebar) Other IDs
Radar 0x06001388 background 0x06004CC1
Minimap 0x06001065 map tile 0x06000261, cursor 0x060011F9, sub-cursor 0x06001377
Chat window 0x0600137D
Vitals window 0x060013B2
Stats window 0x0600138C
Skills window 0x0600138E
Selection spell highlight 0x060011D2
Spell-tier icons (levels IVII) 0x060013F4, 0x060013F5, 0x060013F6, 0x060013F7, 0x060013F8, 0x060013F9, 0x06001F63
Hotbar slot frame 0x06001AB2 (selected), 0x06001AB0 (unselected)
Skill level headers 0x06000F90 (Specialized), 0x06000F86 (Trained), 0x06000F89 (Untrained + Unusable)
Skill line row bg 0x06000F98
Stats line row bg 0x06000F98
"Enter Game" button art 0x06004CB2
Character-selection highlight 0x06001125

The UI-critical dat IDs can be verified by opening the retail client_portal.dat and searching for these DataIds — they should all be RenderSurface records in 0x06xxxxxx with sensible dimensions (16×16 to 256×256).


Integration notes for acdream

Architecture:

  • Implement IUiRenderer over Silk.NET with a single per-frame sprite batcher (similar to Chorizite.OpenGLSDLBackend/FontRenderer.cs) — 4 verts per sprite, flush on texture change or reach of MAX_SPRITES = 10048.
  • Use a single 2D orthographic projection matrix sized to the swapchain extent. Update on window resize.
  • Implement UiIconCache keyed on (DataId, PaletteOverride) with LRU eviction; HUD working-set is small (<1 MB VRAM total).
  • Parse LayoutDesc lazily. When the HUD is opened, read the top-level LayoutDesc, recurse Elements, resolve default state's Media, submit sprite draws in ReadOrder.

State changes:

  • Every element stores its current UIStateId. On mouse/keyboard/game-state change, recompute the target state, swap media, reissue draws.
  • Vital updates arrive as Qualities_UpdateAttribute2nd packets. Store into a PlayerVitals component; the orb renderer subscribes and recomputes fill fractions (no allocation per frame).

Ordering:

  • HUD renders after the 3D world. Use a separate framebuffer or simply a clear-depth / disable-depth pass. Retail draws HUD last into the same swapchain buffer, which is what we should do.

Text:

  • Port Font decoding into UiFont using Font.ForegroundSurfaceDataId + Font.BackgroundSurfaceDataId. Build a glyph dictionary at load time. Use atlas textures the first time the font is used.
  • Outline = background blitted first at the color-darkened-by-0.5; fill = foreground blitted at the requested color.

Cursor:

  • Build HCURSOR (Windows) / SDL cursors at UI state load time; swap via the GLFW / Silk.NET window callback when the hit-tested UI element's active state specifies a MediaDescCursor.

Phase ordering:

  • Fonts, icon cache, LayoutDesc loader — implement as a shared UI core library.
  • Vitals HUD comes first (simplest, clear user-visible acceptance).
  • Radar second (needs world-to-screen; simple polar plot).
  • Chat panel + hotbar third (need proper widget primitives: scrollbox, button with state).
  • Selection indicator + hover names + damage floaters integrate last (depend on the world renderer's camera and the physics picking ray).

Conformance test targets:

  • Load Font 0x40000001 (retail's primary UI font), decode its ForegroundSurfaceDataId, verify glyph count and baselineoffset match recorded golden values from retail dat (can be extracted with ACME).
  • Load RenderSurface 0x06001388 (radar icon), decode to BGRA, byte-compare against known-good BGRA from ACME.
  • Load LayoutDesc 0x21000001 (or whatever the main HUD layout is), parse, verify element count + default-state media IDs.

Once these three pass, the HUD foundation is stable and the rest of the slices (0105) plug in on top of the same ElementDesc machinery.