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

1093 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```text
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` (`0x40000000``0x40000FFF`) 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:**
```text
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:**
```csharp
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:
```text
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:
```text
["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:**
```text
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:**
```csharp
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:**
```text
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:**
```text
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:**
```text
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:**
```text
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`):
```c
// 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`:
```csharp
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":
```c
// 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:**
```text
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 | |
| **`0x40000000``0x40000FFF`** | **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)
```csharp
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.
```csharp
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)
```csharp
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:
```c
// 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)
```csharp
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)
```csharp
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)
```csharp
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)
```csharp
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)
```csharp
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)
```csharp
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.
```text
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):**
```csharp
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.