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.
1093 lines
42 KiB
Markdown
1093 lines
42 KiB
Markdown
# 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 01–05). 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 01–04. 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 8–12 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, ¢er_x, ¢er_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 I–VII) | — | `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 (01–05) plug in on top of the same ElementDesc machinery.
|