acdream/docs/research/retail-ui/05-panels.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

52 KiB
Raw Permalink Blame History

Retail AC Client — UI Panels Reference

This document maps each major UI panel in the retail Asheron's Call client to its decompiled source in acclient.exe, the wire messages that drive it, and a C# port sketch for acdream. Data was cross-referenced against:

  • Decompiled acclient chunks 0x004A00000x005C0000 (22,225 functions, 688K lines of C).
  • ACE.Server.Network.GameMessages + ACE.Server.Network.GameEvent.Events (wire format).
  • ACE.Entity.Enum.* (Channel, ChatType, ChatMessageType, EquipMask, CharacterOption).

Common UI architecture observed in decompilation

Before drilling into individual panels, the decompiled code shows a consistent pattern. Every panel is a C++ object that lives at a this-pointer offset inside a parent "frame" object. The same helper functions recur across all panels:

Address Purpose
FUN_00463c00(0x100000XX) Lookup widget by resource ID (globally-registered UI asset IDs). Returns widget pointer.
FUN_0046a740(&localVar) Assign widget pointer with ref-counted intrusive reference.
FUN_0040b8f0(L"literal") Emit a wide-string chunk into the current text-builder stream (for rich-text lines).
FUN_00407e40(L"literal") Append wide-string to tooltip builder.
FUN_00402490(L"literal", sVar) Append to richer formatted text with length.
FUN_0046f670(a, b) Emit a formatting/delimiter token to a text-layout buffer ((1,0) = newline, (2,0) = tab, (0,0) = section).
FUN_00463c00(0x100002f9) / FUN_00463c00(0x100002fc) / FUN_00463c00(0x100002fd) Text formatters for "field-name" / "value" / "highlight-value" column rendering.
FUN_0042dc80() / FUN_0042cbe0(&hdl, 1) / FUN_0042e590() Tooltip open → content → close sequence.
FUN_004618a0(auStack_90) Anchor tooltip to current widget.
FUN_00460270(resourceId, &callback) Register a global event callback for a resource ID.
FUN_0058f8e0/0058f8b0(&out, obj, kind, flag) Lookup weenie/object name string (kind 2 = article-prefixed name).

Widget class layout observation. Most panels store their child-widget pointers at fixed offsets from the panel this pointer. E.g. the paperdoll panel at param_1+0x604, 0x608, …, 0x664 holds ~25 widget pointers for 16+ slot icons plus container, tooltip overlay, and model-preview widgets. Knowing the offset pattern lets you correlate a panel's widget set purely from one bind-event function.

Resource ID encoding. Button/text/layout asset IDs use the magic prefix 0x10000000 plus a small integer (e.g. 0x100000ab = Strength icon, 0x100000fe = generic "attribute row" layout, 0x100002fc = field value text style, 0x100005c3 = Skill Credits icon). These are indexed into the same dat table the dats use for UI layouts.

Shared abstractions. Panels that show lists of rows (skills, spell icons, inventory cells, fellowship members, allegiance tree) all use the same pattern: a parent container widget, a FUN_0046f670(1,0) row-break emission, then a per-row callback that writes the row's icon + label + value text. Panels with tabs (spells, chat) use a mode-byte stored in the panel's flag field (e.g. spell tab offsets 0x1c * tabIndex + 0x634).

1. Chat window

Location: The chat input/handler is spread across multiple chunks; inbound routing lives in chunk_00570000.c::FUN_00573XXX (the big server-message switch). Channel enumeration comes from GameEventType.ChannelBroadcast = 0x0147 (ACE mapping).

Channels (Channel.cs bitmask)

Channels are a 32-bit bitmask, NOT a single enum value. The chat UI tracks which channels the player is subscribed to and routes incoming ChannelBroadcast events by bit.

Channel Bit Command Notes
Abuse 0x00000001 @abuse
Admin 0x00000002 @admin / @ad
Audit 0x00000004 @audit / @au Echo of enforcement commands to admins
Advocate1-3 0x00000008 / 10 / 20 @advocate
QA1/QA2 0x40 / 0x80
Debug 0x00000100
Sentinel 0x00000200 @sent
Help 0x00000400
Fellow 0x00000800 @f Fellowship
Vassals 0x00001000 @v
Patron 0x00002000 @p
Monarch 0x00004000 @m
AlArqas 0x00008000 Society town channels
Holtburg 0x00010000
Lytelthorpe 0x00020000
Nanto 0x00040000
Rithwic 0x00080000
Samsur 0x00100000
Shoushi 0x00200000
Yanshi 0x00400000
Yaraq 0x00800000
CoVassals 0x01000000 @c
AllegianceBroadcast 0x02000000 @a "Tell All Allegiance Members"
FellowBroadcast 0x04000000 Leader-to-fellowship
SocietyCelHan 0x08000000 Celestial Hand society
SocietyEldWeb 0x10000000 Eldrytch Web
SocietyRadBlo 0x20000000 Radiant Blood
Olthoi 0x40000000 Olthoi-only

ChatType (high-level category sent with each message)

ACE.Entity.Enum.ChatType: Undef, Allegiance, General, Trade, LFG, Roleplay, Society, SocietyCelHan, SocietyEldWeb, SocietyRadBlo, Olthoi.

ChatMessageType (color / filter / tab-routing)

A 32-bit enum used in almost every outbound UI message to colorize and categorize a line:

Value Name Color/Behavior
0x00 Broadcast Default — shopkeepers, allegiance MOTD, crafting results, mana-stone messages
0x01 AllChannels
0x02 Speech "Name says, …"
0x03 Tell Incoming /tell
0x04 OutgoingTell "You tell …"
0x05 System Red warning text
0x06 Combat Damage lines
0x07 Magic Enchantment applied/resisted
0x08 Channel Light pink — @admin, @audit, @av1-3, @sent
0x09 ChannelSend
0x0A Social Bright yellow
0x0B SocialSend Light yellow
0x0C Emote Creature emote text (via HearSpeech/HearRangedSpeech)
0x0D Advancement Level-up / skill-gain
0x0E Abuse Light cyan
0x0F Help Red — urgent-help echo
0x10 Appraisal Assess failure
0x11 Spellcasting Spell syllable text (Malar Quaril, etc)
0x12 Allegiance Allegiance chat
0x13 Fellowship Bright yellow fellowship chat
0x14 WorldBroadcast Green
0x15 CombatEnemy Red
0x16 CombatSelf Pink
0x17 Recall
0x18 Craft
0x19 Salvaging Green
0x1F AdminTell Bright yellow

Wire messages that drive the chat window

  1. GameEventChannelBroadcast (0xF7B0 / 0x0147) — 12+ bytes: uint chatChannel | string16L senderName | string16L messageText. The sender name is empty when the server is echoing your own message back to you, triggering "You say…" rendering.
  2. GameEventTell (0xF7B0 / 0x02BD)string16L messageText | string16L senderName | guid senderID | guid targetID | uint chatMessageType | uint extraZero.
  3. GameMessageSystemChat (0xF7E0)string16L message | int chatMessageType. The main path for system notifications.
  4. GameMessageHearSpeech (0x02BB)string16L messageText | string16L senderName | uint senderID | uint chatMessageType. Creature/player speech in the 3D world.
  5. GameMessageHearRangedSpeech (0x02BC) — same layout; broadcast to a wider radius (quest giver yell).
  6. GameMessageTurbineChat (0xF7DE) — the Turbine overlay for cross-server chat (General, Trade, LFG, Roleplay, Society). Has a nested blob format: uint bytesToFollow | ChatNetworkBlobType type | uint dispatchType | uint 1 | uint 0x000B00B5 | uint 1 | uint 0x000B00B5 | uint 0 | uint nestedBytes | uint channel | byteLen-or-u16Len+UTF16 senderName | byteLen-or-u16Len+UTF16 message | uint 0x0C | guid senderID | uint 0 | ChatType.
  7. GameEventSetTurbineChatChannels (0x0295) — tells the client which Turbine channels to enable (sent at login).

Chat window layout (inferred from message types and ACE logging)

  • Top bar: tab strip (General, Combat, Chat, 1, 2, …). Each tab has a filter mask over ChatMessageType values (ChatDisplayMask / ChatFilterMask enums).
  • Text scroll area: ring buffer of rendered lines. Each line carries its source ChatMessageType so the tab filter can decide to show it; font color is derived from the ChatMessageType too.
  • Input line at the bottom: mode byte (/s say, /t name tell, /f fellow, /a allegiance, @channel for named channels, raw text = last-used mode). The client sends GameActionChatChannel on enter.
  • Filters: Character Options (ListenToAllegianceChat 0x1B, ListenToGeneralChat 0x23, ListenToTradeChat 0x24, ListenToLFGChat 0x25, ListenToRoleplayChat 0x26, ListenToSocietyChat 0x2E, ListenToPKDeathMessages 0x34) control what passes through the filter.

Strings observed

Routing strings from the big server-message switch in chunk_00570000.c:

  • L"You have entered the %s channel.\n" (case 0x51b)
  • L"You have left the %s channel.\n" (case 0x51c)
  • L"That channel doesn\'t exist."
  • L"You can\'t use that channel."
  • L"You\'re already on that channel."
  • L"You\'re not currently on that channel."
  • L"Message Blocked: %s" (case 0x51f — squelched sender)
  • L"%s has been added to the list of people you can hear.\n"
  • L"You are now deaf to player\'s screams.\n"
  • L"You can hear all players once again.\n"

C# port sketch — ChatWindow

namespace AcDream.UI.Panels;

public enum ChatTabKind { General, Combat, Chat1, Chat2, Chat3, Chat4 }

public sealed class ChatTab
{
    public string Label { get; init; }
    public ChatDisplayMask VisibleTypes { get; set; }   // bitmask over ChatMessageType values 0x00-0x1F
    public Channel ChannelFilter { get; set; }          // bitmask over Channel enum
    public readonly RingBuffer<ChatLine> Lines = new(2048);
    public bool UnreadBadge { get; set; }
}

public readonly record struct ChatLine(
    ulong Timestamp,
    ChatMessageType MessageType,
    Channel Channel,
    uint SenderGuid,
    string SenderName,
    string Text,
    Color Color);

public sealed class ChatWindow : IPanel
{
    public IReadOnlyList<ChatTab> Tabs { get; }
    public int ActiveTabIndex { get; set; }
    public ChatInputMode InputMode { get; set; }   // Say / Tell / Fellow / Allegiance / Named("AlArqas")
    public string PendingTellTarget { get; set; }
    public uint SubscribedChannels { get; private set; }  // bitmask

    // Drivers (server→UI events)
    public void OnChannelBroadcast(Channel ch, string sender, string text);
    public void OnTell(string text, string sender, uint senderGuid, uint targetGuid, ChatMessageType type);
    public void OnSystemChat(string text, ChatMessageType type);
    public void OnHearSpeech(string text, string sender, uint senderGuid, ChatMessageType type);
    public void OnTurbineChat(uint channel, string sender, string text, ChatType chatType);
    public void OnSetTurbineChannels(uint[] channels);

    // Outbound (UI→server)
    public void Send(string text);   // routes through InputMode to GameActionChatChannel / Say / Tell etc.
}

2. Character Attributes panel

Location: chunk_00470000.c::FUN_0047ba70 (the monolithic "character sheet" builder, starting at line ~8280 with "Gender:" and running through skills). Strings rendered via FUN_0040b8f0. The sheet body is a single giant switch-style render; each row is emitted via FUN_0046f670(1,0) row-break plus a field/value pair.

Layout (from the render order in FUN_0047ba70)

The sheet renders top-down:

  1. Gender (strings table at PTR_DAT_0081a1c4 — "Male" / "Female" / "Unknown").
  2. Heritage (strings at PTR_DAT_0081a1d0, 5 entries — Aluvian, Gharu'ndim, Sho, Viamontian, Umbraen/Empyrean).
  3. Starting Town (strings at PTR_u_Holtburg_0081a1e4, 4 entries — Holtburg, Shoushi, Yaraq, Sanamar/Rithwic per heritage).
  4. "Attributes" header (row break, layout resource 0x100000fe).
  5. 10 attribute rows (loop iVar10 < 10), each using formatter resource 0x100002fc (label) + 0x100002fd (value):
    • Case 0 — Strength (value at player + 0x184)
    • Case 1 — Endurance (value at +0x188)
    • Case 2 — Coordination (value at +0x18c)
    • Case 3 — Quickness (value at +0x190 = 400 dec)
    • Case 4 — Focus (value at +0x194)
    • Case 5 — Self (value at +0x198)
    • Case 6 — Health (max via FUN_005df4c4, curr via FUN_005c4990(2))
    • Case 7 — Stamina (FUN_005c4990(2) → returns stamina)
    • Case 8 — Mana (FUN_005c4990(6))
    • Case 9 — Skill Credits (value at +0x1b8)
  6. 4 skill-category headers (loop iStack_10 < 4):
    • Case 0 — "Specialized Skills" (filter rank iStack_14 = 3)
    • Case 1 — "Trained Skills" (filter rank 2)
    • Case 2 — "Useable Untrained Skills" (filter rank 1, show only if skill.usableWithoutTraining)
    • Case 3 — "Unuseable Untrained Skills" (filter rank 1, inverse)
  7. Per-skill rows — walk the skill linked list at player + 0x1c4; for each node read skill-id (+0x20), category (FUN_005c4be0), value (FUN_005c5b30), and emit row only if category == iStack_14.

Short form (from chunk_005C0000.c::FUN_005c9d70 — tooltip dispatch)

A small helper that picks an attribute label by 1-based switch: 1 → Strength, 2 → Endurance, 3 → Quickness, 4 → Coordination, 5 → Focus, 6 → Self.

Descriptions from FUN_005c9e10:

  • Strength: "Measures your character's muscular power."
  • Endurance: "Measures how healthy your character is."
  • Quickness: "Measures how fast your character is."
  • Coordination: "Measures your character's reflexes"
  • Focus: "Measures your character's mind and senses."
  • Self: "Measures your character's willpower."

Vitals from FUN_005c9eb0: Maximum Health, Health, Maximum Stamina, Stamina, Maximum Mana, Mana.

Vital descriptions (FUN_005c9f50):

  • Health: "(Endurance/2)\nIf you run out of health, you will die!"
  • Stamina: "(Endurance)\nAffects your actions and movement."
  • Mana: "(Self)\nAffects how much magic you can cast."

Wire drivers

  • GameMessagePrivateUpdateAttribute (0x02E3)uint sequence | PropertyAttribute attr | uint ranks | uint startingValue | uint xpSpent. Sent per-attribute on gain. 21 bytes.
  • GameMessagePrivateUpdateVital (0x02E7)uint sequence | PropertyAttribute2ndLevel vital | uint ranks | uint startingValue | uint xpSpent | uint current. 25 bytes.
  • GameMessagePrivateUpdateAttribute2ndLevel (0x02E9) — max-value refresh for vitals.
  • GameEventPlayerDescription (0xF7B0 / 0x0013) — initial full character dump at login; includes all attributes, vitals, skills, equipped objects, clothing/hair metadata.

C# port sketch — AttributesPanel

public enum PropertyAttribute  { Strength=1, Endurance, Quickness, Coordination, Focus, Self }
public enum PropertyAttribute2ndLevel { MaxHealth=1, Health, MaxStamina, Stamina, MaxMana, Mana }

public readonly record struct AttributeStats(uint Ranks, uint StartingValue, uint XpSpent)
{
    public uint Current => StartingValue + Ranks;
}

public readonly record struct VitalStats(uint Ranks, uint StartingValue, uint XpSpent, uint Current)
{
    public uint Max => StartingValue + Ranks;
}

public sealed class AttributesPanel : IPanel
{
    public Gender Gender { get; set; }
    public HeritageGroup Heritage { get; set; }
    public StartingTown StartingTown { get; set; }

    public AttributeStats Strength, Endurance, Coordination, Quickness, Focus, Self;
    public VitalStats Health, Stamina, Mana;
    public uint SkillCredits;

    public void OnUpdateAttribute(PropertyAttribute attr, AttributeStats stats);
    public void OnUpdateVital(PropertyAttribute2ndLevel vital, VitalStats stats);
    public void OnUpdateSkillCredits(uint credits);  // via PrivateUpdatePropertyInt
}

3. Skills panel

Shares the character sheet with Attributes (both live inside FUN_0047ba70). The 4 skill categories ("Specialized Skills", "Trained Skills", "Useable Untrained Skills", "Unuseable Untrained Skills") appear below the attribute block.

Skill advancement classes (AdvancementClass enum)

1 = Untrained, 2 = Trained, 3 = Specialized.

Display rules (from the decompile)

  • Each skill row is emitted as: [category-header]\n<tab>skill-name<tab>value.
  • Specialized skills show in cyan/white, Trained in white, Untrained-useable in gray, Untrained-unuseable hidden by default (still iterated).
  • "Useable Untrained" means the skill has UsableWithoutTraining property = true (e.g. Run, Jump, Loyalty) AND AdvancementClass == Untrained.
  • For each skill row the decompile also looks up a hashtable at iStack_4 + 8 (keyed by skillId % bucketCount) — likely the skill hash used for secondary display metadata (icon, current training rank).

Buy/Train interaction

The sheet also has buttons (not in this render function but registered via event callbacks earlier) for Train and Specialize — they cost skill credits (displayed in Attributes section) and XP. Success/failure echoes via GameMessageSystemChat. Strings seen in chunk_00570000.c:

  • L"You have failed to alter your skill.\n"
  • L"Your %s skill is already untrained!\n"
  • L"Although you cannot untrain your %s skill, you have succeeded in recovering all the experience you had invested in it.\n"
  • L"Although your augmentation will not allow you to untrain your %s skill, you have succeeded in recovering all the experience you had invested in it.\n"

Wire drivers

  • GameMessagePrivateUpdateSkill (0x02DD)uint sequence | PropertySkill skill | uint ranks | ushort adjustPP | uint advancementClass | uint xpSpent | uint initLevel | uint resistanceAtLastCheck | double lastUsedTime. 37 bytes.
  • GameMessagePrivateUpdateSkillLevel (0x02DF) — periodic rank update.

C# port sketch — SkillsPanel

public enum SkillAdvancementClass { Undef=0, Inactive=1, Untrained=2, Trained=3, Specialized=4 }

public readonly record struct SkillState(
    uint Skill,
    uint Ranks,
    SkillAdvancementClass Class,
    uint XpSpent,
    uint InitLevel,
    uint ResistanceAtLastCheck,
    double LastUsedTime)
{
    public uint Current => InitLevel + Ranks;
}

public sealed class SkillsPanel : IPanel
{
    public readonly Dictionary<Skill, SkillState> Skills = new();
    public void OnUpdateSkill(SkillState s);
    public void RaiseTrainSkill(Skill s);       // sends GameAction TrainSkill
    public void RaiseSpecializeSkill(Skill s);
    public void RaiseUntrainSkill(Skill s);

    public IEnumerable<SkillState> Specialized => Skills.Values.Where(s => s.Class == SkillAdvancementClass.Specialized);
    public IEnumerable<SkillState> Trained     => Skills.Values.Where(s => s.Class == SkillAdvancementClass.Trained);
    public IEnumerable<SkillState> UntrainedUsable;  // needs Skill metadata lookup
}

4. Spell panel

Location: chunk_004C0000.c — the spell-bar code. Entry point FUN_004c68f0 (the spellbar bind function) at address 0x004C68F0.

Spell tabs (7 tabs)

From FUN_004c6500 (returns the active tab index from a mode byte at spellbar + 0x5fc.+0x6d8):

Tab Value Icon resource ID Represents
0 Life 0x100000aa (button) / 0x100000a3 (icon) Life magic
1 Creature 0x100000ab / 0x100000a4 Creature Enchantment
2 Item 0x100000ac / 0x100000a5 Item Enchantment
3 War 0x100000ad / 0x100000a6 War Magic
4 Portal 0x100000ae / 0x100000a7 Portal Magic (recall/summon)
5 (unused retail) 0x100000af / 0x100000a8
6 (unused retail) 0x100000b0 / 0x100000a9
7 Void 0x100005c3 / 0x100005c2 Void Magic (post-ToD)

Each tab is registered into the spellbar via FUN_004c6680(spellbar, buttonId, iconId, tabIndex).

Per-tab spell slot storage

The spellbar keeps 7 slots per tab. Slot storage lives at spellbar + 0x634 + (tabIndex * 0x1c) (28 bytes per tab: 7 slots × 4-byte spell IDs = 28).

Ready/selected spell

  • spellbar + 0x620 = currently-selected spell's container object ID.
  • spellbar + 0x624 = spell ID of the selected spell.
  • iVar4 * 0x1c + 0x640 + spellbar = per-tab "is this tab the current ready tab?" flag byte.

Cast interaction (FUN_004c78XX region, around line 5040-5210)

  • User double-clicks a spell slot → FUN_004c7c50 fires.
  • Single-click just highlights via FUN_006a9640 and shows "Double-click to cast this spell" tooltip (L"%hs\nDouble-click to cast this spell").
  • On cast button press: validates target via DAT_00871e54 (current selection), checks spell-can-target-self via FUN_00588350, and if valid formats L"CAST %hs" + optional L" on %s" and sends via FUN_005abb30.
  • Error paths:
    • L"Select a spell to cast" (no slot filled, but spells known) — FUN_00407e40 tooltip bubble.
    • L"You have no spells ready to cast" (no tab populated at all).
    • L"You must select a target for %hs" (no selection).
    • L"You must select an appropriate target for %hs" (wrong target type).
    • L"You cannot cast this spell upon yourself" (self-cast when not allowed).
    • L"This spell would require a target" / L"This spell would require no target" (target-present/absent mismatch).
    • L"Cannot cast spell on a stack of items."
    • L"This spell cannot be cast on %s".

Wire drivers

  • GameEventMagicUpdateSpell (0x02C1) — adds a known spell to the spellbook.
  • GameEventMagicRemoveSpell (0x01A8) — removes.
  • GameEventMagicUpdateEnchantment (0x02C2) — active enchantment on player (for the spell-bar enchantment overlay).
  • GameEventMagicRemoveEnchantment (0x02C3) / Multiple (0x02C5) / Purge (0x02C6) / Dispel (0x02C78) / PurgeBad (0x0312).
  • GameAction CastUnTargetedSpell / CastTargetedSpell (outbound) — triggers the cast.

C# port sketch — SpellBarPanel

public enum SpellSchool { Life, CreatureEnchantment, ItemEnchantment, War, Portal, Slot5, Slot6, Void }
public const int SlotsPerTab = 7;

public sealed class SpellBarPanel : IPanel
{
    public readonly Dictionary<SpellSchool, uint?[]> Slots = new();
    public SpellSchool ActiveTab { get; set; }
    public int SelectedSlot { get; set; }
    public uint? ReadySpellId { get; private set; }
    public uint? ReadySpellContainerId { get; private set; }

    public SpellBarPanel()
    {
        foreach (var s in Enum.GetValues<SpellSchool>())
            Slots[s] = new uint?[SlotsPerTab];
    }

    public void OnLearnedSpell(uint spellId, SpellSchool school);
    public void OnUnlearnedSpell(uint spellId);
    public void AssignToSlot(SpellSchool tab, int slot, uint spellId);

    public bool TryCast(uint? targetId, out string error);  // emits "Select a spell to cast" etc.
}

public sealed class SpellbookPanel : IPanel
{
    // Full known-spell list (filterable by school / level)
    public readonly HashSet<uint> KnownSpells = new();
    public void OnUpdateSpell(uint spellId) => KnownSpells.Add(spellId);
    public void OnRemoveSpell(uint spellId) => KnownSpells.Remove(spellId);
}

public sealed class EnchantmentPanel : IPanel
{
    // Active buffs/debuffs with countdown
    public readonly Dictionary<uint, Enchantment> Active = new();
    public void OnAdd(Enchantment e);
    public void OnRemove(uint spellId);
    public void OnPurgeAll();
    public void OnPurgeBadOnly();
}

5. Paperdoll / Equipment panel

Location: chunk_004A0000.c::FUN_004a5200 is the slot-router that emits the correct "Drag X here to wear them" tooltip and handles drag-drop assignment. Slot widget pointers are stored at fixed offsets from the panel this pointer (param_1).

Slot table (derived from FUN_004a5200 offsets)

Offset Slot Empty-tooltip string EquipMask flag
+0x604 Necklace L"Drag necklaces here to wear them" NeckWear 0x00008000
+0x608 Left Bracelet L"Drag bracelets here to wear them" WristWearLeft 0x00010000
+0x610 Right Bracelet L"Drag bracelets here to wear them" WristWearRight 0x00020000
+0x60C Left Ring L"Drag rings here to wear them" FingerWearLeft 0x00040000
+0x614 Right Ring L"Drag rings here to wear them" FingerWearRight 0x00080000
+0x618 Weapon L"Drag weapons here to wield them" MeleeWeapon 0x00100000
+0x61C Missile Ammo L"Drag missile ammunition here to wield it" MissileAmmo 0x00800000
+0x620 Shield L"Drag shields here to wield them" Shield 0x00200000
+0x624 Clothing (Shirt) L"Drag clothing items here to wear them" ChestWear 0x00000002
+0x628 Clothing (Pants) L"Drag clothing items here to wear them" AbdomenWear 0x00000004
+0x62C Trinket L"Drag trinkets here to activate them" TrinketOne 0x04000000
+0x630 Cloak L"Drag cloaks here to activate them" Cloak 0x08000000
+0x634 Aetheria Blue (Sigil1 — Lyr) L"Drag a Blue Aetheria sigil here to activate it" SigilOne 0x10000000
+0x638 Aetheria Yellow (Sigil2 — Kor) L"Drag a Yellow Aetheria sigil here to activate it" SigilTwo 0x20000000
+0x63C Aetheria Red (Sigil3 — Tem) L"Drag a Red Aetheria sigil here to activate it" SigilThree 0x40000000
+0x640 Head L"Drag head items here to wear them" HeadWear 0x00000001
+0x644 Chest Armor L"Drag chest items here to wear them" ChestArmor 0x00000200
+0x648 Abdomen Armor L"Drag abdomen items here to wear them" AbdomenArmor 0x00000400
+0x64C Upper Arm Armor L"Drag upper arm items here to wear them" UpperArmArmor 0x00000800
+0x650 Lower Arm Armor L"Drag lower arm items here to wear them" LowerArmArmor 0x00001000
+0x654 Glove Armor L"Drag glove items here to wear them" HandWear 0x00000020
+0x658 Upper Leg Armor L"Drag upper leg items here to wear them" UpperLegArmor 0x00002000
+0x65C Lower Leg Armor L"Drag lower leg items here to wear them" LowerLegArmor 0x00004000
+0x660 Foot Armor L"Drag foot coverings here to wear them" FootWear 0x00000100
+0x664 (cross-slot state)

Additional layout widgets around +0x668 (toggle to armor-view), +0x674 (toggle to clothing-view), +0x670 (paperdoll 3D model preview), +0x678 (sidebar with stats).

Armor/Clothing view toggle

FUN_004a5fa0 at line 2660: the paperdoll has a mode toggle between "clothing view" (shows shirt/pants/jewelry/weapons) and "armor view" (shows all 10 armor slots). The mode is set by event 0x100005be (button id). When toggled, slot widget visibility is flipped via (**)(slotWidget + 0x18))(showFlag) calls — classic "hide 8 widgets, show 10 widgets" pattern.

Drag-drop behavior

  • Drop onto an empty slot: tooltip cycles through the "Drag X here to wear them" messages.
  • Drop onto a filled slot OR double-click on the paperdoll slot: emits "(item name) (%s)\nDouble-click to %s" where the inserted strings are either ("worn", "take off") for armor/clothing or ("wielded", "unwield") for weapons/shield (line 2619-2624 branch bVar1).
  • Failed drag (wrong item type for slot): L"You can\'t put that item there" via FUN_004a4de0.
  • Error branches come from the server: L"You're already wearing a helm.", L"You're already wearing chest armor.", …, and similar for every body region (chunk_00560000.c line 1199-1394).

3D paperdoll model preview

FUN_004a4c70 (starting line ~2150) — loads a preview scene at widget +0x68c (a rendered 3D object overlay), placing a scaled copy of the character's visual (ObjDesc) for live armor/weapon visualization. When an item is dragged onto a slot, the server sends an updated GameMessageObjDescEvent which triggers a repaint here.

Wire drivers

  • GameMessageObjDescEvent (0xF625) — full object-description (clothing base + palette swatches + hair) refresh. Any slot change triggers this on every nearby player.
  • GameEventWieldObject (0x0023) — confirms that an equip request succeeded. Contains the item's guid + new wield location.
  • GameEventInventoryPutObjInContainer (0x0022) — inverse: item moved back to backpack.
  • GameActionPutItemInContainer / GameActionWieldItem — outbound equip/unequip requests.

C# port sketch — PaperdollPanel

public sealed class PaperdollPanel : IPanel
{
    // 25 slots, keyed by EquipMask bit
    public readonly Dictionary<EquipMask, uint?> Equipment = new();

    // The visual 3D model
    public ObjDescSnapshot PaperdollObjDesc { get; set; }

    public PaperdollViewMode View { get; set; } = PaperdollViewMode.Clothing;

    public void OnWieldObject(uint itemGuid, EquipMask location);
    public void OnUnwieldObject(uint itemGuid);
    public void OnObjDescUpdate(ObjDescSnapshot desc);

    public bool TryDragEquip(uint itemGuid, EquipMask targetSlot, out string errorString);
    public bool TryDoubleClickUnequip(EquipMask slot);
}

public enum PaperdollViewMode { Clothing, Armor }

The slot table MUST match the offsets above exactly when porting drag-drop dispatch — the retail client dispatches via offset comparisons, not enum values.

6. Inventory panel

The inventory panel is heavily intertwined with the paperdoll (same window in retail) and with containers (sub-windows when you open a pack). Inventory code is scattered across chunk_004B0000.c (container list renderer), chunk_004E0000.c (container placement), and chunk_00580000.c (item-use and pickup dispatch).

Layout

  • Side tabs: Main Pack + 6 numbered Side Packs (up to 7 packs total, each up to 24 items). Main pack = character's base container.
  • Grid: fixed-size icon grid inside each pack. Items beyond visible range use a scroll-arrow or "Page X" header (see L"Page %d" format at chunk_004B0000.c lines 2881/2903).
  • Footer: Burden / capacity text. Burden status strings come from chunk_00560000.c: L"You are encumbered!" (line 157), L"You are severely encumbered!" (163).

Key interactions (from chunk_00580000.c dispatch)

  • Right-click: context menu with Use / Examine / Split / Drop / Give options.
  • Double-click: GameActionUseItem (equips or activates).
  • Left-click drag: move between slots. If cross-container, sends GameActionPutItemInContainer.
  • Drag to paperdoll: equip.
  • Drag to ground: drop (GameActionDropItem).
  • Drag to NPC: give (GameActionGiveObjectRequest).
  • Shift-click on stack: split (GameActionStackableSplit).

Error strings (partial)

  • L"You can't pick that up!", L"You are too encumbered to carry that!", L"You cannot pick up more of that item!" (chunk_00570000 line 1849-3018).
  • L"You must first pick up the %s", L"The %s is locked", L"You must open the %s first", L"Cannot give %s to %s", L"Move cancelled", L"You cannot do that in mid air".
  • L"The destination stack is already full.", L"You cannot merge different types of items.", L"You cannot merge items while they are being traded.".
  • L"Cannot place container in item list", L"Cannot place item in container list", L"Already attempting to place %s here", L"The %s cannot accept items".
  • L"You are not carrying the %s" (line 4826 in chunk_004B0000).

Stack / split

The split UI uses a numeric entry dialog. Split-related errors:

  • L"Cannot split the stack to sell it", L"Cannot split the stack for dwelling costs", L"The %s can't be split", L"You must split the stack before selling it.".

Wire drivers

  • GameMessageCreateObject (0xF745) — the universal "object exists, here's its data" — inventory items arrive through this.
  • GameEventInventoryPutObjInContainer (0x0022), GameEventItemServerSaysContainId, GameEventItemServerSaysMoveItem — confirm server-side state after an inventory action.
  • GameMessageSetStackSize (0x0197) — stack merged/split result.
  • GameMessageInventoryRemoveObject (0x0024) — item deleted.
  • GameMessagePickupEvent (0xF74A) — outbound, client picking up a ground object.
  • GameEventViewContents (0x0196) — sent when client opens a container (includes full item list).

C# port sketch — InventoryPanel

public sealed class InventoryItem
{
    public uint Guid;
    public uint IconResourceId;
    public string Name;
    public uint StackSize;
    public uint Burden;
    public uint Value;
    public EquipMask ValidLocations;
    public ItemType Type;
    public uint ContainerGuid;       // who holds us
    public int PlacementIndex;
    public IsEquipped Equipped;
}

public sealed class InventoryPanel : IPanel
{
    public uint CharacterGuid { get; }
    public readonly Dictionary<uint, InventoryItem> Items = new();   // by GUID
    public readonly List<uint> OpenContainerStack = new();            // Main + side packs currently viewed

    public int SelectedTab { get; set; }
    public int BurdenCurrent { get; private set; }
    public int BurdenMax { get; private set; }
    public BurdenStatus Status { get; private set; }  // Fine, Encumbered, SeverelyEncumbered

    public void OnCreateObject(InventoryItem item);
    public void OnRemoveObject(uint guid);
    public void OnStackSize(uint guid, uint newSize);
    public void OnPutInContainer(uint itemGuid, uint newContainer, int placement);
    public void OnViewContents(uint containerGuid, InventoryItem[] contents);

    public void RaiseMoveItem(uint guid, uint destContainer, int placement);
    public void RaiseSplitItem(uint guid, uint newSize);
    public void RaiseDropItem(uint guid);
    public void RaiseGiveItem(uint guid, uint targetGuid);
}

public enum BurdenStatus { Fine, Encumbered, SeverelyEncumbered }

7. Quickbar

The retail client has a 10-slot quickbar bound to 10 keys, visible along the bottom-center of the screen. The decompiled binding code resides near the main-window input dispatch; I did not find the exact function address during this sweep (would be in a chunk around 0x004D0000 given the "ID_InputMap_*" strings in chunk_004D0000.c line 4808, e.g. "ID_InputMap_CameraAlternateControls").

Behaviors

  • Slots accept any item from inventory or any spell from the spellbar.
  • On slot-press: if item → GameActionUseItem(guid); if spell → equivalent of double-clicking that spell slot.
  • Shift+number rearranges; ctrl+number clears. (Standard AC layout.)

C# port sketch — Quickbar

public enum QuickbarAction { None, UseItem, CastSpell, PlayMacro }

public sealed class QuickbarSlot
{
    public QuickbarAction Action;
    public uint Target;   // item-guid, spell-id, or macro-id
    public uint IconId;
}

public sealed class Quickbar : IPanel
{
    public readonly QuickbarSlot[] Slots = new QuickbarSlot[10];
    public int ActiveBar { get; set; }           // retail has only one, but plan for plugin expansion
    public void Press(int slotIndex);            // resolve action → dispatch through Inventory/SpellBar
    public void Assign(int slotIndex, QuickbarSlot slot);
}

8. Allegiance panel

Location: rendering lives in ACE code as an AllegianceProfile struct sent inside GameEventAllegianceUpdate (0x0020); the client side is in a chunk I did not fully decompile in this sweep (would be around 0x004B/0x004C).

Content rendered

  • Monarch name + title.
  • Patron (your direct superior) — may be null.
  • Vassals — list of direct reports with level, gender, online/offline.
  • Rank (your depth in the tree), allegiance name, MOTD (if set), officer level.
  • Recruited count, percentage breakdown.
  • Bans and officer list (if you're monarch).

Events / strings

  • L"You're already sworn your Allegiance" / L"You are not in an allegiance!" (chunk_00570000 lines 1527/1541).
  • L"No patron from which to break!", L"Your Allegiance has been dissolved!".
  • L"You have been teleported too recently!" (recall-related).
  • L"That is an invalid officer level.".
  • L"Your allegiance is currently: %s." / L"Your allegiance is now: %s.".
  • L"Your allegiance name has been cleared.".
  • L"Banned Characters: " (list header).
  • L"You are banned from %s's allegiance!".
  • L"Allegiance information for %hs%s:\n" (chunk_00560000 line 7245 — the @allegiance info dump).

Wire drivers

  • GameEventAllegianceUpdate (0x0020) — full allegiance tree + rank + profile.
  • GameEventAllegianceLoginNotification (0x027A) — "Vassal X is online" popups.
  • GameEventAllegianceInfoResponse (0x027C) — response to @allegiance info.
  • GameEventAllegianceAllegianceUpdateDone (0x01C8) — action-complete marker.
  • GameEventAllegianceUpdateAborted (0x0003).

C# port sketch — AllegiancePanel

public sealed class AllegianceMember
{
    public uint Guid;
    public string Name;
    public int Level;
    public Gender Gender;
    public HeritageGroup Heritage;
    public bool Online;
    public uint PatronGuid;
    public int Rank;           // depth
    public AllegianceOfficerLevel OfficerLevel;
    public List<AllegianceMember> Vassals = new();
}

public sealed class AllegiancePanel : IPanel
{
    public AllegianceMember Monarch;
    public AllegianceMember Patron;
    public List<AllegianceMember> Vassals;
    public string AllegianceName;
    public string AllegianceMotd;
    public uint MyRank;
    public AllegianceOfficerLevel MyOfficerLevel;
    public void OnAllegianceUpdate(AllegianceMember root, AllegianceMember self);
    public void OnLoginNotification(string name, bool online);
}

9. Fellowship panel

Content rendered (from GameEventFellowshipFullUpdate 0x02BE)

Each fellow line includes:

  • Name (string16L).
  • Level.
  • Health/Stamina/Mana max + current (shown as bars).
  • cpCached / lumCached (undistributed exp and luminance shares).
  • ShareLoot flag (0 or 0x10).

Fellowship-level fields:

  • FellowshipName (up to 256 chars).
  • FellowshipLeaderGuid.
  • ShareXP / EvenShare / Open flags.
  • IsLocked flag.
  • DepartedMembers list (with cooldown timers).
  • FellowshipLocks (post-ToD locks).

Interaction

  • Leader can recruit (drag player onto fellowship UI or @fellow recruit PlayerName).
  • Leader can dismiss (@fellow dismiss name).
  • Any member can quit (@fellow quit).
  • Leader can disband the fellowship.

Strings observed

  • L"You are unprepared to cast a spell" (unrelated but appears in same switch).
  • L"You must be the leader of a Fellowship".
  • L"Your Fellowship is full".
  • L"That Fellowship name is not permitted".
  • L"You do not belong to a Fellowship.".
  • L"This fellowship is locked; " + name + " cannot be recruited into the fellowship." (line 2543).
  • L"The fellowship is locked, you were not added to the fellowship.".
  • L"The fellowship is locked; you cannot open locked fellowships.".

Wire drivers

  • GameEventFellowshipFullUpdate (0x02BE).
  • GameEventFellowshipUpdateFellow (0x02C0) — per-fellow incremental.
  • GameEventFellowshipDisband (0x02BF).
  • GameEventFellowshipQuit (0x00A3).
  • GameEventFellowshipDismiss (0x00A4).
  • GameEventFellowshipFellowUpdateDone (0x01C9) / FellowshipFellowStatsDone (0x01CA).

C# port sketch — FellowshipPanel

public sealed class Fellow
{
    public uint Guid;
    public string Name;
    public int Level;
    public uint MaxHealth, MaxStamina, MaxMana;
    public uint CurrentHealth, CurrentStamina, CurrentMana;
    public uint CpCached;
    public uint LumCached;
    public bool ShareLoot;
}

public sealed class FellowshipPanel : IPanel
{
    public string FellowshipName;
    public uint LeaderGuid;
    public bool ShareXP, EvenShare, Open, IsLocked;
    public readonly Dictionary<uint, Fellow> Members = new();
    public readonly Dictionary<uint, Fellow> Departed = new();

    public bool IAmLeader(uint myGuid) => myGuid == LeaderGuid;

    public void OnFullUpdate(FellowshipSnapshot snap);
    public void OnUpdateFellow(Fellow f);
    public void OnDisband();
    public void RaiseRecruit(string name);
    public void RaiseDismiss(uint guid);
    public void RaiseQuit();
    public void RaiseToggleShareXP();
}

10. Macros panel

Retail macros were stored client-side in ~/Documents/Asheron's Call/settings/(character).ppf or equivalent, not sent through the wire protocol. The decompiled code I scanned did not contain a dedicated macro UI function in the chunks explicitly assigned here; it lives in a higher chunk (around 0x005D0x005F based on the config file saver strings).

Behaviors (from in-game retail memory)

  • @save name and @load name for macros (L"Please use @help saveui for proper usage." and L"Please use @help loadui for proper usage." are the help prompts, chunk_00570000 lines 208/270).
  • A macro is a short script of actions: text chats, cast spell, use item, delay N seconds.
  • Bound to quickbar slots or standalone hotkeys.
  • Also included "Automation" — auto-select nearest monster and attack.

C# port sketch — MacrosPanel

This is one of the panels where acdream's plugin API becomes crucial (the "scripting/macros" requirement in CLAUDE.md).

public enum MacroStepKind { Chat, CastSpell, UseItem, Delay, Wait, Attack, Emote, Custom }

public sealed class MacroStep
{
    public MacroStepKind Kind;
    public string Text;        // for Chat/Emote
    public uint TargetId;      // for CastSpell/UseItem
    public TimeSpan Duration;  // for Delay/Wait
    public string CustomId;    // for plugin-provided step
}

public sealed class Macro
{
    public string Name;
    public int HotkeyIndex;
    public List<MacroStep> Steps = new();
}

public sealed class MacrosPanel : IPanel
{
    public readonly Dictionary<string, Macro> Macros = new();
    public void Save(Macro m);
    public void Delete(string name);
    public void Execute(string name, IPluginHost host);  // dispatches through plugin API
}

11. Options panel

Location: the options panel writes into GameActionSetSingleCharacterOption (see CharacterOption.cs). The rendering chunk is around 0x005D0x005F (config / saved-UI layout chunks), not fully scanned here.

Options available

From CharacterOption.cs, 55 named options split across CharacterOptions1 (gameplay) and CharacterOptions2 (display / filters). Some highlights:

Option Bit group Description
AutoRepeatAttacks 1 Auto-attack
IgnoreAllegianceRequests 1
IgnoreFellowshipRequests 1
IgnoreAllTradeRequests 1
DisableMostWeatherEffects 1 Graphics
AlwaysDaylightOutdoors 2 Graphics
LetOtherPlayersGiveYouItems 1
KeepCombatTargetsInView 1
Display3dTooltips 1
AttemptToDeceiveOtherPlayers 1
RunAsDefaultMovement 1
StayInChatModeAfterSendingMessage 1
AdvancedCombatInterface 1
AutoTarget 1
VividTargetingIndicator 1
ShareFellowshipExpAndLuminance 1
AcceptCorpseLootingPermissions 1
ShareFellowshipLoot 1
AutomaticallyAcceptFellowshipRequests 1
SideBySideVitals 1 UI layout
ShowCoordinatesByTheRadar 1
DisplaySpellDurations 1
DisableHouseRestrictionEffects 1
DragItemToPlayerOpensTrade 1
ShowAllegianceLogons 1
UseChargeAttack 1
UseCraftingChanceOfSuccessDialog 1
ListenToAllegianceChat 1
AllowOthersToSeeYourDateOfBirth 2
AllowOthersToSeeYourAge 2
AllowOthersToSeeYourChessRank 2
AllowOthersToSeeYourFishingSkill 2
AllowOthersToSeeYourNumberOfDeaths 2
DisplayTimestamps 2
SalvageMultipleMaterialsAtOnce 2
ListenToGeneralChat 2
ListenToTradeChat 2
ListenToLFGChat 2
ListenToRoleplayChat 2
AppearOffline 2
AllowOthersToSeeYourNumberOfTitles 2
UseMainPackAsDefaultForPickingUpItems 2
LeadMissileTargets 2
UseFastMissiles 2
FilterLanguage 2
ConfirmUseOfRareGems 2
ListenToSocietyChat 2
ShowYourHelmOrHeadGear 2
DisableDistanceFog 2 Graphics
UseMouseTurning 2
ShowYourCloak 2
LockUI 2
ListenToPKDeathMessages 2

Additional graphics/audio options (retail, not in protocol — client-local)

  • Resolution + windowed/fullscreen.
  • Draw distance (both landblock chunks loaded and scenery density).
  • Texture detail / vertex lighting / environment lighting toggles.
  • Gamma / contrast.
  • Master volume, music volume, speech volume, effects volume.
  • Radar color theme, UI scale.

Wire drivers

  • GameActionSetSingleCharacterOption outbound on toggle.
  • GameActionSetCharacterOption1Flag / Option2Flag bulk.
  • The server persists and replays at login via PlayerDescription.

C# port sketch — OptionsPanel

public sealed class OptionsPanel : IPanel
{
    public readonly Dictionary<CharacterOption, bool> Options = new();

    public GraphicsOptions Graphics { get; set; }   // client-local
    public AudioOptions Audio { get; set; }         // client-local

    public bool Get(CharacterOption opt) => Options.GetValueOrDefault(opt, false);
    public void Set(CharacterOption opt, bool value);   // writes back to server + persists locally
    public void LoadFromDescription(uint options1, uint options2);
}

12. Login screen / server select

Login screen layout (from retail behavior)

  • Title banner (AC logo).
  • Username field (masked).
  • Password field (masked).
  • Server selection dropdown (loaded from a ServerList.ini-style file).
  • Play / Create Account / Exit buttons.
  • Error strings: varying dialogs surface rejections from GameMessageAccountBanned (0xF7C1), CharacterError (0xF659).

Wire drivers

  • Initial LoginRequestConnectRequestConnectResponse (UDP handshake, see docs/research/2026-04-12-movement-deep-dive.md and holtburger session code).
  • GameMessageCharacterList (0xF658) arrives after encrypted handshake — contains the account's 11-slot character list.
  • GameMessageServerName (0xF7E1) — displayed at top of character select.
  • GameMessageAccountBanned (0xF7C1) + GameMessageBootAccount — rejection paths.

C# port sketch — LoginScreen

public sealed class ServerEntry
{
    public string Name;
    public string Host;
    public int Port;
    public int Population;
    public bool Online;
}

public sealed class LoginScreen : IPanel
{
    public List<ServerEntry> Servers;
    public ServerEntry SelectedServer { get; set; }
    public string Username { get; set; }
    public string PasswordSecure { get; set; }  // kept in pinned SecureString

    public event Action<LoginAttempt> OnSubmit;
    public void ShowError(string message);
}

13. Character select screen

Layout

  • Slot-list on left: 11 character slots (paid accounts), 1 free character always unlocked.
  • 3D preview of selected character rotating on a pedestal (server sends appearance + equipped ObjDesc).
  • Play / Create / Delete buttons.
  • Server name at top.

Wire drivers

  • GameMessageCharacterList (0xF658) — the list.
  • GameMessageCharacterError (0xF659) — generic error (name taken, account suspended, etc.).
  • GameMessageCharacterDelete (0xF655) outbound.
  • GameMessageCharacterCreate (0xF656) outbound.
  • GameMessageCharacterCreateResponse (0xF643) server's answer.
  • GameMessageCharacterEnterWorldRequest (0xF7C8) outbound when Play clicked.
  • GameMessageCharacterEnterWorldServerReady (0xF7DF) when world server takes over.
  • GameMessageCharacterRestore (0xF7D9) used for restoring deleted characters within the 1-hour grace window.

Strings seen

From chunk_00470000 line 8280-8315 — this is the CREATION screen render, NOT select:

  • L"Gender: "
  • L"Heritage: "
  • L"Starting Town: "
  • Then the attribute preview as documented in panel #2.

C# port sketch — CharacterSelectScreen

public sealed class CharacterSlot
{
    public uint Guid;
    public string Name;
    public int Level;
    public int SecondsRemainingToDelete;  // for post-delete grace period
    public ObjDescSnapshot Appearance;
    public bool IsFreeSlot;
}

public sealed class CharacterSelectScreen : IPanel
{
    public string ServerName;
    public List<CharacterSlot> Slots;
    public CharacterSlot Selected;

    public event Action<uint> OnPlay;
    public event Action<uint> OnDelete;
    public event Action OnCreateNew;

    public void OnCharacterList(CharacterSlot[] slots);
    public void OnError(string errorMessage);
}

Cross-panel patterns for acdream

Based on the decompilation sweep above, the client uses a small set of recurring abstractions. When porting, implement these once and reuse:

  1. IPanel interface with Bind() / Unbind() / OnPacketReceived() hooks. Every panel registers to its relevant GameMessageOpcode / GameEventType values; the dispatcher routes to them.
  2. WidgetLookup — resource-ID → widget pointer. Mirrors retail's FUN_00463c00 pattern. Each panel stores its child widgets in a record type with named fields.
  3. RichTextBuilder — builds formatted rows of [label][tab][value]\n with style tokens. Replaces the FUN_0040b8f0 / FUN_0046f670 / FUN_00402490 trio.
  4. TooltipBuilder — sequence Open → Emit → Close → Anchor. Replaces FUN_0042dc80 / FUN_0040b8f0 / FUN_0042cbe0 / FUN_004618a0 / FUN_0042e590.
  5. DragDropRouter — single entry point for pick/drop events with a slot-kind dispatch table. The paperdoll's 25-slot switch and inventory grid both route through this.
  6. RowList<T> (for skills, spells, fellows, allegiance vassals) — generic panel-internal renderer that iterates a filtered collection, emits one row per item, supports sorting/scrolling.
  7. PropertyUpdateBus — the PrivateUpdateProperty{Int,Int64,Bool,Float,String,DataID} family of 0x02CD0x02D8 messages all go through one bus that panels subscribe to by PropertyId. This is cleaner than 6 separate wiring paths.

Summary table — panel ↔ chunk ↔ wire message

Panel Primary chunk / function Main wire input
Chat window chunk_00570000.c big switch ChannelBroadcast 0x0147, Tell 0x02BD, SystemChat 0xF7E0, HearSpeech 0x02BB, TurbineChat 0xF7DE
Character Attributes chunk_00470000.c::FUN_0047ba70 + chunk_005C0000.c::FUN_005c9d70/e10/eb0 PrivateUpdateAttribute 0x02E3, PrivateUpdateVital 0x02E7, PlayerDescription 0x0013
Skills shared with Attributes PrivateUpdateSkill 0x02DD, PrivateUpdateSkillLevel 0x02DF
Spell bar chunk_004C0000.c::FUN_004c68f0 + 004c6500/6680/7c50 MagicUpdateSpell 0x02C1, MagicRemoveSpell 0x01A8, enchantment events 0x02C2-8
Paperdoll chunk_004A0000.c::FUN_004a5200 + 004a5fa0 ObjDescEvent 0xF625, WieldObject 0x0023, InventoryPutObjInContainer 0x0022
Inventory chunk_004B0000.c + chunk_004E0000.c + chunk_00580000.c ObjectCreate 0xF745, SetStackSize 0x0197, InventoryRemoveObject 0x0024, ViewContents 0x0196
Quickbar around chunk_004D0000.c (input map) client-local (actions dispatch back to inventory/spell)
Allegiance not scanned here AllegianceUpdate 0x0020, AllegianceInfoResponse 0x027C, AllegianceLoginNotification 0x027A
Fellowship not scanned here FellowshipFullUpdate 0x02BE, FellowshipUpdateFellow 0x02C0, FellowshipDisband 0x02BF
Macros not scanned here client-local
Options not fully scanned GameActionSetSingleCharacterOption outbound
Login pre-world chunks UDP handshake + ServerName 0xF7E1
Character select post-handshake pre-world CharacterList 0xF658, CharacterError 0xF659

What was NOT fully mapped in this sweep

Honest gaps to flag for the next research pass:

  • Macros panel — needs a scan of 0x005D0x005F chunks for the config save/load handlers and macro execution tokenizer.
  • Options panel rendering — needs a scan for the actual layout function (where the 55 character options get paired with checkboxes + sliders).
  • Allegiance tree widget — the actual tree-layout widget with expand/collapse arrows; it's probably in a chunk adjacent to fellowship.
  • Fellowship member-list widget — same; only the data side is in ACE. The render function is in retail's client and I did not find its address in this sweep.
  • Quickbar widget class offsets — known it exists from input-map strings but the widget-storage offsets were not identified.
  • Chat window widget IDs — the tab-button resource IDs, tab-filter storage offsets, and input-box widget ID need a dedicated pass through the chunk that owns chat_window or similar identifier.

These gaps are documented here so a follow-up research task can pick them up rather than re-discover them.