feat(ui): D.2b item interaction + retail cursors + live character sheet

Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02
UI architecture review mandated before commit:

- ItemInteractionController: single owner of double-click use/equip/
  container-open, targeted-use mode (health kits), drag-out drop;
  toolbar shortcut drags don't drop the real item. ItemEquipRules for
  multi-slot (coat) coverage via equip masks.
- Cursor phase: CursorFeedbackController (semantic priority chain:
  drag > resize > window-move > target-mode > text) + RetailCursorCatalog
  (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState
  0x00564630) resolved through the portal EnumIDMap chain by
  RetailCursorResolver; RetailCursorManager applies dat cursor art to the
  OS cursor. Register row AP-72 covers the OS standard-cursor fallback.
- Character window goes live: CharacterSheetProvider owns sheet assembly,
  XP-curve/raise-cost math and the raise flow — extracted out of
  GameWindow per Code Structure Rule 1 instead of committing the ~430-line
  feature body there. Optimistic XP/credit debits go through eventful
  store APIs (new ClientObjectTable.UpdateInt64Property +
  LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw
  property-dictionary writes; register row AP-73 covers the still-missing
  raise ledger (#163).
- RetailWindowFrame: the shared nine-slice window mount recipe; the
  character window uses it, remaining windows migrate via #164.
- Status-bar buttons toggle inventory/character windows; retail row-major
  backpack ordering; WorldSession.SendUseWithTarget + raise/train sends.

GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the
sheet/raise logic is unit-tested in CharacterSheetProviderTests instead
of trapped in the god object. Build green; full suite 3,286 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 09:18:43 +02:00
parent e3fc7ac5ba
commit b7dc91a053
74 changed files with 6669 additions and 238 deletions

View file

@ -659,8 +659,12 @@ public sealed class GameWindow : IDisposable
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.Testing.RetailUiAutomationScriptRunner? _uiAutomationRunner;
private AcDream.App.UI.CursorFeedbackController? _cursorFeedbackController;
private RetailCursorManager? _retailCursorManager;
// Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern).
private AcDream.App.UI.Layout.ToolbarController? _toolbarController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
@ -764,6 +768,9 @@ public sealed class GameWindow : IDisposable
// and out keeps the right skills.
private int _lastSeenRunSkill = -1;
private int _lastSeenJumpSkill = -1;
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
// K.1b: this field is RESERVED — written when entering / leaving player
// mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now
// never consumed by MovementInput (mouse never drives character yaw —
@ -1829,6 +1836,28 @@ public sealed class GameWindow : IDisposable
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
sendUse: g => _liveSession?.SendUse(g),
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => _liveSession?.SendDropItem(item),
toast: text => _debugVm?.AddToast(text));
_cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(_itemInteractionController);
_retailCursorManager = new RetailCursorManager(_dats!, _datLock);
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
Objects, LocalPlayer,
playerGuid: () => _playerServerGuid,
activeToonName: () => _activeToonKey,
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
canSendRaise: () => _liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
sendRaiseAttribute: (statId, cost) => _liveSession?.SendRaiseAttribute(statId, cost),
sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost),
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
@ -2079,6 +2108,7 @@ public sealed class GameWindow : IDisposable
peaceDigits: toolbarPeaceDigits,
warDigits: toolbarWarDigits,
emptyDigits: toolbarEmptyDigits,
itemInteraction: _itemInteractionController,
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
@ -2176,6 +2206,47 @@ public sealed class GameWindow : IDisposable
}
else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
// Phase D.2b-C: live Character window (Attributes / Skills / Titles) from
// LayoutDesc 0x2100002E. Starts hidden and is toggled by the status-bar
// character button; stats use the same retail-bound sheet shape as the studio
// until the live character stat feed is wired.
AcDream.App.UI.Layout.ImportedLayout? characterLayout;
lock (_datLock)
characterLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, 0x2100002Eu, ResolveChrome, vitalsDatFont, ResolveDatFont);
if (characterLayout is not null)
{
AcDream.App.UI.Layout.CharacterStatController.Bind(
characterLayout,
data: _characterSheetProvider!.BuildSheet,
datFont: vitalsDatFont,
rowDatFont: ResolveDatFont(0x40000001u) ?? vitalsDatFont,
spriteResolve: ResolveChrome,
onRaiseRequest: _characterSheetProvider.HandleRaiseRequest,
onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Character));
// Retail-authored content is 380px tall but the skill list wants to
// grow: bottom-edge-only resize up to 760px, X locked to the dat width.
AcDream.App.UI.Layout.RetailWindowFrame.Mount(
_uiHost.Root, characterLayout.Root, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{
Left = 540f,
Top = 18f,
MaxHeight = 760f,
ResizeX = false,
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
Visible = false,
ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Bottom,
ContentClickThrough = false,
WindowName = AcDream.App.UI.WindowNames.Character,
});
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
}
else Console.WriteLine("[D.2b-C] character: LayoutDesc 0x2100002E not found.");
// Drain plugin-registered markup panels (buffered before the GL
// window opened) into the same UiRoot tree. A faulty plugin markup
// file is isolated — logged + skipped, never crashes the client.
@ -2294,13 +2365,16 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa
? (int?)sa.Current : null,
datFont: vitalsDatFont,
ownerName: _characterSheetProvider!.CharacterName,
contentsEmptySprite: contentsEmpty,
sideBagEmptySprite: sideBagEmpty,
mainPackEmptySprite: mainPackEmpty,
sendUse: g => _liveSession?.SendUse(g),
sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g),
sendPutItemInContainer: (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement));
_liveSession?.SendPutItemInContainer(item, container, placement),
itemInteraction: _itemInteractionController,
onClose: () => CloseRetailWindow(AcDream.App.UI.WindowNames.Inventory));
// Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear +
// wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid).
@ -2312,7 +2386,8 @@ public sealed class GameWindow : IDisposable
// Empty equip slots show a visible frame (same square as the inventory grid) so every
// slot position is seen + usable; the live 3D character (the doll) is Slice 2.
emptySlotSprite: contentsEmpty,
datFont: vitalsDatFont); // Slice 2: caption the "Slots" toggle button
datFont: vitalsDatFont,
itemInteraction: _itemInteractionController); // Slice 2: caption the "Slots" toggle button
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
@ -2324,6 +2399,25 @@ public sealed class GameWindow : IDisposable
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
}
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
_toolbarController?.BindWindowToggles(
toggleInventory: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory),
toggleCharacter: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Character));
SyncToolbarWindowButtons();
if (_options.UiProbeEnabled)
{
void ProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
var probe = new AcDream.App.UI.Testing.RetailUiAutomationProbe(
_uiHost.Root,
Objects,
ProbeLog);
_uiAutomationRunner = new AcDream.App.UI.Testing.RetailUiAutomationScriptRunner(
probe,
_options.UiProbeScript,
_options.UiProbeDump,
ProbeLog);
}
}
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
@ -2689,6 +2783,13 @@ public sealed class GameWindow : IDisposable
// and jumps undershot retail by ~30 % at typical
// attribute levels.
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null)
{
_characterSheetProvider.SkillTable = skillTable;
_characterSheetProvider.ExperienceTable =
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
_dats, Console.WriteLine);
}
AcDream.Core.Net.GameEventWiring.WireAll(
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
@ -9498,6 +9599,8 @@ public sealed class GameWindow : IDisposable
if (_options.RetailUi && _uiHost is not null)
{
_uiHost.Tick(deltaSeconds);
_uiAutomationRunner?.Tick(deltaSeconds);
UpdateRetailCursorFeedback();
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
}
@ -11654,6 +11757,42 @@ public sealed class GameWindow : IDisposable
// first EnterWorld.
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private string _activeToonKey = "default";
private bool ToggleRetailWindow(string name)
{
bool visible = _uiHost?.ToggleWindow(name) ?? false;
SyncToolbarWindowButtons();
return visible;
}
private void CloseRetailWindow(string name)
{
_uiHost?.HideWindow(name);
SyncToolbarWindowButtons();
}
private void SyncToolbarWindowButtons()
{
if (_toolbarController is null || _uiHost is null) return;
_toolbarController.SetInventoryOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Inventory));
_toolbarController.SetCharacterOpen(_uiHost.IsWindowVisible(AcDream.App.UI.WindowNames.Character));
}
private void UpdateRetailCursorFeedback()
{
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null)
return;
var feedback = _cursorFeedbackController.Update(_uiHost.Root);
_retailCursorManager.Apply(_input.Mice, feedback);
}
private void OnUiDragReleasedOutside(object payload, int x, int y)
{
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
_itemInteractionController?.DropToWorld(itemPayload);
}
// L.0 follow-up: persisted-settings cache populated by
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
// not gated on DevToolsEnabled). The Settings PANEL construction
@ -12046,7 +12185,7 @@ public sealed class GameWindow : IDisposable
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
// does not fire while the chat input holds focus. Null _uiHost =
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
_uiHost?.ToggleWindow(AcDream.App.UI.WindowNames.Inventory);
ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
@ -12153,7 +12292,9 @@ public sealed class GameWindow : IDisposable
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
if (_itemInteractionController?.IsTargetModeActive == true)
_itemInteractionController.CancelTargetMode();
else if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
else if (_playerMode)
{
@ -12291,6 +12432,12 @@ public sealed class GameWindow : IDisposable
if (picked is uint guid)
{
if (_itemInteractionController?.IsTargetModeActive == true)
{
_itemInteractionController.AcquireTarget(guid);
return;
}
SelectedGuid = guid;
string label = DescribeLiveEntity(guid);
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
@ -12324,6 +12471,8 @@ public sealed class GameWindow : IDisposable
}
else
{
if (_itemInteractionController?.IsTargetModeActive == true)
return;
_debugVm?.AddToast("Nothing to select");
}
}

View file

@ -0,0 +1,142 @@
using AcDream.App.UI;
using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.Core;
using Silk.NET.Input;
namespace AcDream.App.Rendering;
/// <summary>Applies retail cursor feedback to Silk using dat MediaDescCursor art when available.</summary>
public sealed class RetailCursorManager
{
private readonly DatCollection _dats;
private readonly object _datLock;
private readonly RetailCursorResolver _globalCursors;
private readonly Dictionary<uint, RawImage> _imagesBySurface = new();
private readonly HashSet<uint> _missingSurfaces = new();
private CursorFeedbackKind _lastKind = (CursorFeedbackKind)(-1);
private UiCursorMedia _lastCursor;
public RetailCursorManager(DatCollection dats, object datLock)
{
_dats = dats;
_datLock = datLock;
_globalCursors = new RetailCursorResolver(dats, datLock);
}
public void Apply(IEnumerable<IMouse> mice, CursorFeedback feedback)
{
if (feedback.Cursor.IsValid && TryGetImage(feedback.Cursor.File, out var image))
{
ApplyCustom(mice, feedback.Cursor, image);
_lastKind = feedback.Kind;
_lastCursor = feedback.Cursor;
return;
}
if (_globalCursors.TryResolve(feedback.Kind, out var globalCursor)
&& TryGetImage(globalCursor.File, out var globalImage))
{
ApplyCustom(mice, globalCursor, globalImage);
_lastKind = feedback.Kind;
_lastCursor = globalCursor;
return;
}
ApplyStandard(mice, StandardCursorFor(feedback.Kind));
_lastKind = feedback.Kind;
_lastCursor = default;
}
private void ApplyCustom(IEnumerable<IMouse> mice, UiCursorMedia cursorMedia, RawImage image)
{
if (_lastCursor.Equals(cursorMedia))
return;
foreach (var mouse in mice)
{
var cursor = mouse.Cursor;
cursor.Image = image;
cursor.HotspotX = cursorMedia.HotspotX;
cursor.HotspotY = cursorMedia.HotspotY;
if (cursor.Type != CursorType.Custom)
cursor.Type = CursorType.Custom;
}
}
private void ApplyStandard(IEnumerable<IMouse> mice, StandardCursor desired)
{
if (_lastCursor.Equals(default(UiCursorMedia)) && _lastKind != (CursorFeedbackKind)(-1)
&& StandardCursorFor(_lastKind) == desired)
return;
foreach (var mouse in mice)
{
var cursor = mouse.Cursor;
var standard = desired;
if (!cursor.IsSupported(standard))
standard = StandardCursor.Arrow;
if (!cursor.IsSupported(standard))
continue;
if (cursor.Type != CursorType.Standard)
cursor.Type = CursorType.Standard;
if (cursor.StandardCursor != standard)
cursor.StandardCursor = standard;
}
}
private bool TryGetImage(uint renderSurfaceId, out RawImage image)
{
if (_imagesBySurface.TryGetValue(renderSurfaceId, out image))
return true;
if (_missingSurfaces.Contains(renderSurfaceId))
return false;
DecodedTexture? decoded = DecodeCursorSurface(renderSurfaceId);
if (decoded is null || decoded.Width <= 0 || decoded.Height <= 0 || decoded.Rgba8.Length == 0)
{
_missingSurfaces.Add(renderSurfaceId);
image = default;
return false;
}
image = new RawImage(decoded.Width, decoded.Height, decoded.Rgba8);
_imagesBySurface[renderSurfaceId] = image;
return true;
}
private DecodedTexture? DecodeCursorSurface(uint renderSurfaceId)
{
lock (_datLock)
{
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
return null;
Palette? palette = rs.DefaultPaletteId != 0
? _dats.Get<Palette>(rs.DefaultPaletteId)
: null;
return SurfaceDecoder.DecodeRenderSurface(rs, palette);
}
}
private static StandardCursor StandardCursorFor(CursorFeedbackKind kind)
=> kind switch
{
CursorFeedbackKind.Text => StandardCursor.IBeam,
CursorFeedbackKind.WindowMove => StandardCursor.ResizeAll,
CursorFeedbackKind.ResizeHorizontal => StandardCursor.HResize,
CursorFeedbackKind.ResizeVertical => StandardCursor.VResize,
CursorFeedbackKind.ResizeDiagonalNwse => StandardCursor.NwseResize,
CursorFeedbackKind.ResizeDiagonalNesw => StandardCursor.NeswResize,
CursorFeedbackKind.Drag => StandardCursor.Hand,
CursorFeedbackKind.DragAccept => StandardCursor.ResizeAll,
CursorFeedbackKind.DragReject => StandardCursor.NotAllowed,
CursorFeedbackKind.TargetPending => StandardCursor.Crosshair,
CursorFeedbackKind.TargetValid => StandardCursor.ResizeAll,
CursorFeedbackKind.TargetInvalid => StandardCursor.NotAllowed,
_ => StandardCursor.Arrow,
};
}

View file

@ -0,0 +1,76 @@
using AcDream.App.UI;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
/// <summary>Resolves retail global cursor enum IDs through the portal EnumIDMap chain.</summary>
internal sealed class RetailCursorResolver
{
private readonly DatCollection _dats;
private readonly object _datLock;
private readonly Dictionary<uint, uint> _didByEnum = new();
private readonly HashSet<uint> _missingEnums = new();
public RetailCursorResolver(DatCollection dats, object datLock)
{
_dats = dats;
_datLock = datLock;
}
public bool TryResolve(CursorFeedbackKind kind, out UiCursorMedia cursor)
{
cursor = default;
if (!RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec))
return false;
return TryResolve(spec, out cursor);
}
internal bool TryResolve(RetailCursorSpec spec, out UiCursorMedia cursor)
{
cursor = default;
if (!spec.IsValid)
return false;
if (_didByEnum.TryGetValue(spec.EnumId, out uint cachedDid))
{
cursor = new UiCursorMedia(cachedDid, spec.HotspotX, spec.HotspotY);
return true;
}
if (_missingEnums.Contains(spec.EnumId))
return false;
uint did = ResolveDidByEnum(spec.EnumId);
if (did == 0)
{
_missingEnums.Add(spec.EnumId);
return false;
}
cursor = new UiCursorMedia(did, spec.HotspotX, spec.HotspotY);
_didByEnum[spec.EnumId] = did;
return true;
}
private uint ResolveDidByEnum(uint cursorEnum)
{
lock (_datLock)
{
uint masterDid = (uint)_dats.Portal.Header.MasterMapId;
if (masterDid == 0)
return 0;
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master) || master is null)
return 0;
if (!master.ClientEnumToID.TryGetValue(RetailCursorCatalog.CursorEnumTable, out uint cursorMapDid))
return 0;
if (!_dats.Portal.TryGet<EnumIDMap>(cursorMapDid, out var cursorMap) || cursorMap is null)
return 0;
return cursorMap.ClientEnumToID.TryGetValue(cursorEnum, out uint did) ? did : 0;
}
}
}

View file

@ -41,7 +41,9 @@ public sealed record RuntimeOptions(
bool DumpLiveSpawns,
int? LegacyStreamRadius,
bool RetailUi,
string? AcDir)
string? AcDir,
bool UiProbeDump,
string? UiProbeScript)
{
/// <summary>
/// Build options from the process environment. Used by
@ -85,13 +87,18 @@ public sealed record RuntimeOptions(
// top of the quality preset's radii. Null when unset or invalid.
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")));
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")));
}
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
public bool HasLiveCredentials =>
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
/// <summary>True when the opt-in retail UI automation probe should be constructed.</summary>
public bool UiProbeEnabled => UiProbeDump || !string.IsNullOrEmpty(UiProbeScript);
private static bool IsExactlyOne(string? s)
=> string.Equals(s, "1", StringComparison.Ordinal);

View file

@ -122,12 +122,14 @@ public static class SampleData
/// synthetic character. Values are plausible retail-scale numbers so the
/// report renders with well-proportioned text in all sections.
/// </summary>
public static CharacterSheet SampleCharacter() => new()
public static CharacterSheet SampleCharacter() => SampleCharacter(null);
public static CharacterSheet SampleCharacter(string? name) => new()
{
Name = "Studio Player",
Name = string.IsNullOrWhiteSpace(name) ? "Studio Player" : name,
Level = 126,
Race = "Aluvian",
Heritage = "Aluvian Heritage",
Gender = "Female",
Heritage = "Aluvian",
Title = "the Adventurer",
BirthDate = "January 5, 2001",
PlayTime = "2 years, 114 days, 4 hours",
@ -166,6 +168,7 @@ public static class SampleData
// Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
// Formula bracket at value=10: ExperienceToAttributeLevel(11) ExperienceToAttributeLevel(10).
AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
AttributeRaise10Costs = new long[] { 0L, 950L, 1_000L, 0L, 1_100L, 1_050L, 900L, 880L, 1_120L },
// Real SkillTable icon IDs and train/specialize costs from client_portal.dat
// SkillTable 0x0E000004. gmSkillUI groups/sorts these at bind time.

View file

@ -0,0 +1,255 @@
namespace AcDream.App.UI;
/// <summary>
/// Semantic cursor state for the retail-look UI. Retail stores actual cursor art
/// as MediaDescCursor on UI states; this controller owns the behavior decision so
/// the visual backend can apply dat cursors without
/// changing item/window interaction code.
/// </summary>
public enum CursorFeedbackKind
{
Default,
Text,
WindowMove,
ResizeHorizontal,
ResizeVertical,
ResizeDiagonalNwse,
ResizeDiagonalNesw,
Drag,
DragAccept,
DragReject,
TargetPending,
TargetValid,
TargetInvalid,
}
public readonly record struct CursorFeedback(CursorFeedbackKind Kind, UiCursorMedia Cursor = default)
{
public static CursorFeedback Default { get; } = new(CursorFeedbackKind.Default);
}
public readonly record struct CursorFeedbackSnapshot(
object? DragPayload = null,
UiItemSlot.DragAcceptState DragAccept = UiItemSlot.DragAcceptState.None,
ResizeEdges ActiveResizeEdges = ResizeEdges.None,
ResizeEdges HoverResizeEdges = ResizeEdges.None,
bool WindowMoveActive = false,
bool HoverWindowMove = false,
bool HoverUi = false,
bool HoverTextEdit = false,
uint HoverTargetGuid = 0);
public sealed class CursorFeedbackController
{
private readonly ItemInteractionController? _itemInteraction;
public CursorFeedbackController(ItemInteractionController? itemInteraction = null)
{
_itemInteraction = itemInteraction;
}
public CursorFeedback Current { get; private set; } = CursorFeedback.Default;
public CursorFeedback Update(UiRoot root)
{
ArgumentNullException.ThrowIfNull(root);
UiElement? hover = root.Pick(root.MouseX, root.MouseY);
var snapshot = new CursorFeedbackSnapshot(
DragPayload: root.DragPayload,
DragAccept: FindHoveredItemSlot(hover)?.DragAcceptVisual ?? UiItemSlot.DragAcceptState.None,
ActiveResizeEdges: root.ActiveResizeEdges,
HoverResizeEdges: root.HoverResizeEdges,
WindowMoveActive: root.IsWindowMoveActive,
HoverWindowMove: root.HoverWindowMove,
HoverUi: hover is not null,
HoverTextEdit: hover?.IsEditControl == true,
HoverTargetGuid: ResolveUseTargetGuid(hover));
var kind = ResolveKind(snapshot);
Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover, kind));
return Current;
}
public CursorFeedback Update(CursorFeedbackSnapshot snapshot)
{
Current = Resolve(snapshot);
return Current;
}
public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot)
=> new(ResolveKind(snapshot));
private CursorFeedbackKind ResolveKind(CursorFeedbackSnapshot snapshot)
{
if (snapshot.DragPayload is not null)
{
return snapshot.DragAccept switch
{
UiItemSlot.DragAcceptState.Accept => CursorFeedbackKind.DragAccept,
UiItemSlot.DragAcceptState.Reject => CursorFeedbackKind.DragReject,
_ => CursorFeedbackKind.Drag,
};
}
ResizeEdges resizeEdges = snapshot.ActiveResizeEdges != ResizeEdges.None
? snapshot.ActiveResizeEdges
: snapshot.HoverResizeEdges;
if (resizeEdges != ResizeEdges.None)
return KindForResize(resizeEdges);
if (snapshot.WindowMoveActive)
return CursorFeedbackKind.WindowMove;
if (_itemInteraction?.IsTargetModeActive == true)
{
if (snapshot.HoverTargetGuid != 0)
{
return _itemInteraction.IsCurrentTargetCompatible(snapshot.HoverTargetGuid)
? CursorFeedbackKind.TargetValid
: CursorFeedbackKind.TargetInvalid;
}
return snapshot.HoverUi
? CursorFeedbackKind.TargetInvalid
: CursorFeedbackKind.TargetPending;
}
if (snapshot.HoverWindowMove)
return CursorFeedbackKind.WindowMove;
if (snapshot.HoverTextEdit)
return CursorFeedbackKind.Text;
return CursorFeedbackKind.Default;
}
private static CursorFeedbackKind KindForResize(ResizeEdges edges)
{
bool horizontal = (edges & (ResizeEdges.Left | ResizeEdges.Right)) != 0;
bool vertical = (edges & (ResizeEdges.Top | ResizeEdges.Bottom)) != 0;
if (horizontal && vertical)
{
bool nwse =
((edges & ResizeEdges.Left) != 0 && (edges & ResizeEdges.Top) != 0)
|| ((edges & ResizeEdges.Right) != 0 && (edges & ResizeEdges.Bottom) != 0);
return nwse ? CursorFeedbackKind.ResizeDiagonalNwse : CursorFeedbackKind.ResizeDiagonalNesw;
}
if (horizontal) return CursorFeedbackKind.ResizeHorizontal;
if (vertical) return CursorFeedbackKind.ResizeVertical;
return CursorFeedbackKind.Default;
}
private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover, CursorFeedbackKind kind)
{
foreach (var stateName in CursorStateNamesForKind(kind))
{
var semantic = FindCursorForState(hover, stateName);
if (semantic.IsValid)
return semantic;
}
var capturedCursor = FindActiveCursor(captured);
if (capturedCursor.IsValid)
return capturedCursor;
var hoverCursor = FindActiveCursor(hover);
if (hoverCursor.IsValid)
return hoverCursor;
return default;
}
private static IReadOnlyList<string> CursorStateNamesForKind(CursorFeedbackKind kind)
=> kind switch
{
CursorFeedbackKind.DragAccept => new[]
{
"Drag_rollover_accept",
"ItemSlot_DragOver_Accept",
"ItemSlot_DragOver_DropIn",
},
CursorFeedbackKind.TargetValid => new[]
{
"Drag_rollover_accept",
"ItemSlot_DragOver_Accept",
"ItemSlot_DragOver_DropIn",
"Csm_highlight",
},
CursorFeedbackKind.DragReject or CursorFeedbackKind.TargetInvalid => new[]
{
"Drag_rollover_reject",
"ItemSlot_DragOver_Reject",
"Csm_ghosted",
},
CursorFeedbackKind.Drag => new[]
{
"ItemSlot_DragOver_Normal",
"Drag_rollover_accept",
},
CursorFeedbackKind.TargetPending => new[]
{
"DDDMode",
"Csm_normal",
},
CursorFeedbackKind.WindowMove => new[]
{
"Csm_highlight",
"Csm_normal",
},
_ => Array.Empty<string>(),
};
private static UiCursorMedia FindCursorForState(UiElement? element, string stateName)
{
while (element is not null)
{
var cursor = element.CursorForState(stateName, allowFallback: false);
if (cursor.IsValid)
return cursor;
element = element.Parent;
}
return default;
}
private static UiCursorMedia FindActiveCursor(UiElement? element)
{
while (element is not null)
{
var cursor = element.ActiveCursor();
if (cursor.IsValid)
return cursor;
element = element.Parent;
}
return default;
}
private static UiItemSlot? FindHoveredItemSlot(UiElement? element)
{
while (element is not null)
{
if (element is UiItemSlot slot)
return slot;
element = element.Parent;
}
return null;
}
private static uint ResolveUseTargetGuid(UiElement? element)
{
while (element is not null)
{
uint provided = element.UseTargetGuidProvider?.Invoke() ?? 0u;
if (provided != 0)
return provided;
if (element is UiItemSlot { ItemId: not 0 } slot)
return slot.ItemId;
element = element.Parent;
}
return 0u;
}
}

View file

@ -0,0 +1,38 @@
using AcDream.Core.Items;
namespace AcDream.App.UI;
internal static class ItemEquipRules
{
// Retail CPlayerSystem::AutoWear / gmPaperDollUI inventory mask: 0x08007FFF.
public const EquipMask AutoWearMask =
EquipMask.HeadWear
| EquipMask.ChestWear
| EquipMask.AbdomenWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear
| EquipMask.HandWear
| EquipMask.UpperLegWear
| EquipMask.LowerLegWear
| EquipMask.FootWear
| EquipMask.ChestArmor
| EquipMask.AbdomenArmor
| EquipMask.UpperArmArmor
| EquipMask.LowerArmArmor
| EquipMask.UpperLegArmor
| EquipMask.LowerLegArmor
| EquipMask.Cloak;
public static bool IsAutoWearItem(ClientObject item)
=> (item.ValidLocations & AutoWearMask) != EquipMask.None;
public static EquipMask ResolvePaperdollDropWieldMask(ClientObject item, EquipMask targetMask)
{
if ((item.ValidLocations & targetMask) == EquipMask.None)
return EquipMask.None;
return IsAutoWearItem(item)
? item.ValidLocations
: item.ValidLocations & targetMask;
}
}

View file

@ -0,0 +1,383 @@
using System;
using AcDream.Core.Items;
namespace AcDream.App.UI;
/// <summary>
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
/// target acquisition, and drag-out drops here instead of duplicating
/// ItemHolder::UseObject fragments in each panel.
/// </summary>
public sealed class ItemInteractionController
{
private static readonly EquipMask[] AutoEquipOrder =
{
EquipMask.HeadWear,
EquipMask.ChestWear,
EquipMask.AbdomenWear,
EquipMask.UpperArmWear,
EquipMask.LowerArmWear,
EquipMask.HandWear,
EquipMask.UpperLegWear,
EquipMask.LowerLegWear,
EquipMask.FootWear,
EquipMask.ChestArmor,
EquipMask.AbdomenArmor,
EquipMask.UpperArmArmor,
EquipMask.LowerArmArmor,
EquipMask.UpperLegArmor,
EquipMask.LowerLegArmor,
EquipMask.NeckWear,
EquipMask.WristWearLeft,
EquipMask.WristWearRight,
EquipMask.FingerWearLeft,
EquipMask.FingerWearRight,
EquipMask.Shield,
EquipMask.MissileAmmo,
EquipMask.MeleeWeapon,
EquipMask.MissileWeapon,
EquipMask.Held,
EquipMask.TwoHanded,
EquipMask.TrinketOne,
EquipMask.Cloak,
EquipMask.SigilOne,
EquipMask.SigilTwo,
EquipMask.SigilThree,
};
private const long RetailUseThrottleMs = 200;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<long> _nowMs;
private readonly Action<uint>? _sendUse;
private readonly Action<uint, uint>? _sendUseWithTarget;
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
private readonly Action<string>? _toast;
private long _lastUseMs = long.MinValue / 2;
public ItemInteractionController(
ClientObjectTable objects,
Func<uint> playerGuid,
Action<uint>? sendUse,
Action<uint, uint>? sendUseWithTarget,
Action<uint, uint>? sendWield,
Action<uint>? sendDrop,
Func<long>? nowMs = null,
Action<string>? toast = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_sendUse = sendUse;
_sendUseWithTarget = sendUseWithTarget;
_sendWield = sendWield;
_sendDrop = sendDrop;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
}
public event Action? StateChanged;
public uint PlayerGuid => _playerGuid();
public uint PendingSourceItem { get; private set; }
public bool IsTargetModeActive => PendingSourceItem != 0;
public bool IsPendingSource(uint itemGuid)
=> itemGuid != 0 && itemGuid == PendingSourceItem;
public bool IsCurrentTargetCompatible(uint targetGuid)
{
if (!IsTargetModeActive || targetGuid == 0) return false;
var source = _objects.Get(PendingSourceItem);
return source?.Useability is { } useability
&& TargetCompatible(source, targetGuid, useability);
}
public bool ActivateItem(uint itemGuid)
{
if (itemGuid == 0) return false;
if (IsTargetModeActive)
return AcquireTarget(itemGuid);
var item = _objects.Get(itemGuid);
if (item is null) return false;
if (!ConsumeUseThrottle())
return true;
if (item.Useability is { } targetedUse
&& ItemUseability.IsTargeted(targetedUse)
&& SourceCompatible(item, targetedUse))
{
EnterTargetMode(itemGuid);
return true;
}
if (IsContainer(item))
{
_sendUse?.Invoke(itemGuid);
return true;
}
if (TryAutoWield(item))
return true;
if (item.Useability is { } directUse
&& ItemUseability.IsDirectUseable(directUse)
&& SourceCompatible(item, directUse))
{
_sendUse?.Invoke(itemGuid);
return true;
}
return false;
}
public bool AcquireTarget(uint targetGuid)
{
if (!IsTargetModeActive || targetGuid == 0) return false;
uint sourceGuid = PendingSourceItem;
ClearTargetMode();
var source = _objects.Get(sourceGuid);
if (source?.Useability is not { } useability)
return false;
if (!TargetCompatible(source, targetGuid, useability))
return false;
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
return true;
}
public bool AcquireSelfTarget()
=> IsTargetModeActive && AcquireTarget(_playerGuid());
public void CancelTargetMode()
{
if (!IsTargetModeActive) return;
ClearTargetMode();
}
public bool DropToWorld(ItemDragPayload payload)
{
ArgumentNullException.ThrowIfNull(payload);
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return false;
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is null)
return false;
_objects.MoveItemOptimistic(payload.ObjId, newContainerId: 0u, newSlot: -1);
_sendDrop?.Invoke(payload.ObjId);
return true;
}
private void EnterTargetMode(uint sourceGuid)
{
PendingSourceItem = sourceGuid;
StateChanged?.Invoke();
var name = _objects.Get(sourceGuid)?.Name;
if (!string.IsNullOrWhiteSpace(name))
_toast?.Invoke($"Choose a target for the {name}");
}
private void ClearTargetMode()
{
PendingSourceItem = 0;
StateChanged?.Invoke();
}
private bool ConsumeUseThrottle()
{
long now = _nowMs();
if (now - _lastUseMs < RetailUseThrottleMs)
return false;
_lastUseMs = now;
return true;
}
private bool TryAutoWield(ClientObject item)
{
if (item.ValidLocations == EquipMask.None
|| item.CurrentlyEquippedLocation != EquipMask.None)
return false;
EquipMask mask = BestAvailableEquipMask(item);
if (mask == EquipMask.None)
{
_toast?.Invoke("That slot is already in use");
return false;
}
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
return false;
_sendWield?.Invoke(item.ObjectId, (uint)mask);
return true;
}
private EquipMask BestAvailableEquipMask(ClientObject item)
{
if (ItemEquipRules.IsAutoWearItem(item))
return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
return FirstAvailableEquipMask(item);
}
private bool AutoWearIsLegal(ClientObject item)
{
uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
if ((item.Priority & priorityMask) == 0)
return true;
EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations;
return GetEquippedObjectAtLocation(occupiedLocations, item.Priority, item.ObjectId) is null;
}
private EquipMask FirstAvailableEquipMask(ClientObject item)
{
foreach (var mask in AutoEquipOrder)
{
if ((item.ValidLocations & mask) == EquipMask.None)
continue;
if (!EquipMaskOccupied(mask, item.ObjectId))
return mask;
}
return EquipMask.None;
}
private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
{
foreach (var o in _objects.Objects)
{
if (o.ObjectId == exceptGuid)
continue;
if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None)
continue;
if (IsEquippedByPlayer(o))
return true;
}
return false;
}
private uint EquippedAutoWearPriorityMask(uint exceptGuid)
{
uint mask = 0;
foreach (var o in _objects.Objects)
{
if (o.ObjectId == exceptGuid)
continue;
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
continue;
if (IsEquippedByPlayer(o))
mask |= o.Priority;
}
return mask;
}
private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
{
EquipMask mask = EquipMask.None;
foreach (var o in _objects.Objects)
{
if (o.ObjectId == exceptGuid)
continue;
if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
continue;
if (IsEquippedByPlayer(o))
mask |= o.CurrentlyEquippedLocation;
}
return mask;
}
private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid)
{
if (locationMask == EquipMask.None)
return null;
foreach (var o in _objects.Objects)
{
if (o.ObjectId == exceptGuid)
continue;
if (!IsEquippedByPlayer(o))
continue;
if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
continue;
if ((o.Priority & priority) != 0 || priority == 0)
return o;
}
return null;
}
private static bool IsContainer(ClientObject item)
=> item.ContainerTypeHint != 0
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0;
private bool SourceCompatible(ClientObject source, uint useability)
{
uint flags = ItemUseability.SourceFlags(useability);
if ((flags & ItemUseability.Remote) != 0) return true;
if ((flags & ItemUseability.Viewed) != 0) return true;
if ((flags & ItemUseability.Self) != 0 && source.ObjectId == _playerGuid()) return true;
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(source)) return true;
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(source)) return true;
return false;
}
private bool TargetCompatible(ClientObject source, uint targetGuid, uint useability)
{
uint flags = ItemUseability.TargetFlags(useability);
uint player = _playerGuid();
if (targetGuid == source.ObjectId && (flags & ItemUseability.ObjSelf) != 0)
return true;
if (targetGuid == player)
return (flags & ItemUseability.Self) != 0;
var target = _objects.Get(targetGuid);
if (target is null)
return (flags & ItemUseability.Remote) != 0;
if (source.TargetType is { } targetType && targetType != 0
&& ((uint)target.Type & targetType) == 0)
return false;
if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(target)) return true;
if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(target)) return true;
if ((flags & ItemUseability.Viewed) != 0 && IsCarriedByPlayer(target)) return true;
if ((flags & ItemUseability.Remote) != 0) return true;
return false;
}
private bool IsWieldedByPlayer(ClientObject item)
=> IsEquippedByPlayer(item);
private bool IsEquippedByPlayer(ClientObject item)
{
uint player = _playerGuid();
return item.CurrentlyEquippedLocation != EquipMask.None
&& (item.WielderId == player || item.ContainerId == player);
}
private bool IsCarriedByPlayer(ClientObject item)
{
uint player = _playerGuid();
uint container = item.ContainerId;
for (int hops = 0; container != 0 && hops < 8; hops++)
{
if (container == player) return true;
container = _objects.Get(container)?.ContainerId ?? 0u;
}
return false;
}
}

View file

@ -0,0 +1,68 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail character identity display helpers for gmStatManagementUI.
/// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls
/// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0),
/// then appends the current CharacterTitleTable title when one is active.
/// </summary>
internal static class CharacterIdentityText
{
public const uint GenderPropertyId = 0x71u;
public const uint HeritageGroupPropertyId = 0xBCu;
public static string StatHeaderLine(CharacterSheet sheet)
{
string? heritage = !string.IsNullOrWhiteSpace(sheet.Heritage)
? sheet.Heritage
: sheet.Race;
string? title = StripLeadingArticle(sheet.Title);
if (string.IsNullOrWhiteSpace(sheet.Gender))
return Join(heritage, title);
return Join(sheet.Gender, heritage, title);
}
public static string? GenderDisplayName(int gender) => gender switch
{
1 => "Male",
2 => "Female",
_ => null,
};
public static string? HeritageGroupDisplayName(int heritageGroup) => heritageGroup switch
{
1 => "Aluvian",
2 => "Gharu'ndim",
3 => "Sho",
4 => "Viamontian",
5 => "Umbraen",
6 => "Gearknight",
7 => "Tumerok",
8 => "Lugian",
9 => "Empyrean",
10 => "Penumbraen",
11 => "Undead",
12 => "Olthoi",
13 => "Olthoi",
_ => null,
};
private static string Join(params string?[] parts)
{
return string.Join(" ", parts
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p!.Trim()));
}
private static string? StripLeadingArticle(string? title)
{
if (string.IsNullOrWhiteSpace(title)) return null;
string trimmed = title.Trim();
return trimmed.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase)
? trimmed[4..]
: trimmed;
}
}

View file

@ -26,10 +26,13 @@ public sealed class CharacterSheet
/// <summary>Character level.</summary>
public int Level { get; init; }
/// <summary>Gender display string, e.g. "Female". Null = omit.</summary>
public string? Gender { get; init; }
/// <summary>Race string, e.g. "Aluvian". Null = omit.</summary>
public string? Race { get; init; }
/// <summary>Heritage string, e.g. "Aluvian Heritage". Null = omit.</summary>
/// <summary>Heritage group display string, e.g. "Aluvian". Null = omit.</summary>
public string? Heritage { get; init; }
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
@ -108,11 +111,11 @@ public sealed class CharacterSheet
public long UnassignedXp { get; init; }
// ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
// Retail formula: ExperienceToAttributeLevel(value + 1) ExperienceToAttributeLevel(value).
// We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is
// at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows
// (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana).
// Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute.
// Retail formula for x1: ExperienceToAttributeLevel(value + 1) xpSpent.
// Retail formula for x10: ExperienceToAttributeLevel(value + min(10, remaining)) xpSpent.
// Cost 0 means the attribute is at max or not trainable. Ordered to match AttrRows:
// Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana.
// Source: gmAttributeUI::GetCostToRaise/GetCostToRaise10 (0x0049cb80/0x0049cc70).
/// <summary>
/// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
@ -122,6 +125,12 @@ public sealed class CharacterSheet
/// </summary>
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
/// <summary>
/// XP cost to raise each of the 9 attributes/vitals by up to 10 retail steps,
/// in the same display order as <see cref="AttributeRaiseCosts"/>.
/// </summary>
public long[] AttributeRaise10Costs { get; init; } = Array.Empty<long>();
/// <summary>
/// Skills shown on the Character window Skills tab. Retail gmSkillUI groups these by
/// advancement class, then sorts each group alphabetically by skill name.

View file

@ -0,0 +1,478 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Player;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Assembles the live <see cref="CharacterSheet"/> for the retail Character
/// window and owns the raise-request flow (wire send + optimistic local
/// apply). Extracted from <c>GameWindow</c> (Code Structure Rule 1: sheet
/// assembly + XP-curve math is feature logic, not wiring).
///
/// <para>Retail property ids and the decomp anchors for every field are
/// documented on <see cref="CharacterSheet"/>; the raise-cost formulas are
/// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family).</para>
///
/// <para><b>State ownership:</b> optimistic debits go through the owning
/// store's eventful APIs — <see cref="ClientObjectTable.UpdateIntProperty"/> /
/// <see cref="ClientObjectTable.UpdateInt64Property"/> (fires ObjectUpdated)
/// when the player object is in the table, else
/// <see cref="LocalPlayerState.DebitIntProperty"/> /
/// <see cref="LocalPlayerState.DebitInt64Property"/> (fires CharacterChanged).
/// Never write the raw property dictionaries from UI code. The next server
/// snapshot remains authoritative over every optimistic value.</para>
/// </summary>
public sealed class CharacterSheetProvider
{
/// <summary>PropertyInt64 2 = unassigned (banked) XP — CharacterSheet.UnassignedXp.</summary>
private const uint UnassignedXpPropertyId = 2u;
/// <summary>
/// Skill-credit debit fallback chain, most-specific first. Retail reads
/// InqInt(0x18) for the Attributes-tab footer, InqInt(0xc0) for available
/// and InqInt(0xb5) for total skill credits (see CharacterSheet docs);
/// the live server population varies, so debit whichever is present.
/// </summary>
private static readonly uint[] SkillCreditPropertyIds = { 0x18u, 0xC0u, 0xB5u };
private readonly ClientObjectTable _objects;
private readonly LocalPlayerState _localPlayer;
private readonly Func<uint> _playerGuid;
private readonly Func<string?>? _activeToonName;
private readonly Func<string, CharacterSheet>? _fallbackSheet;
private readonly Func<bool>? _canSendRaise;
private readonly Action<uint, ulong>? _sendRaiseAttribute;
private readonly Action<uint, ulong>? _sendRaiseVital;
private readonly Action<uint, ulong>? _sendRaiseSkill;
private readonly Action<uint, uint>? _sendTrainSkill;
/// <summary>Portal SkillTable (0x0E000004) — set by the host once dats load.</summary>
public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; }
/// <summary>Portal ExperienceTable (0x0E000018) — set by the host once dats load.</summary>
public DatReaderWriter.DBObjs.ExperienceTable? ExperienceTable { get; set; }
public CharacterSheetProvider(
ClientObjectTable objects,
LocalPlayerState localPlayer,
Func<uint> playerGuid,
Func<string?>? activeToonName = null,
Func<string, CharacterSheet>? fallbackSheet = null,
Func<bool>? canSendRaise = null,
Action<uint, ulong>? sendRaiseAttribute = null,
Action<uint, ulong>? sendRaiseVital = null,
Action<uint, ulong>? sendRaiseSkill = null,
Action<uint, uint>? sendTrainSkill = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_activeToonName = activeToonName;
_fallbackSheet = fallbackSheet;
_canSendRaise = canSendRaise;
_sendRaiseAttribute = sendRaiseAttribute;
_sendRaiseVital = sendRaiseVital;
_sendRaiseSkill = sendRaiseSkill;
_sendTrainSkill = sendTrainSkill;
}
// ── Sheet assembly ─────────────────────────────────────────────────────
/// <summary>Best display name: active toon key, else the live object's name, else "Player".</summary>
public string CharacterName()
{
string? toon = _activeToonName?.Invoke();
if (!string.IsNullOrWhiteSpace(toon) && toon != "default")
return toon;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid)?.Name is { Length: > 0 } objectName)
return objectName;
return "Player";
}
/// <summary>
/// Build the sheet from live state; falls back to the injected sample
/// sheet (Studio data) until any live character data has arrived.
/// Called per repaint by the Character window's bound widgets.
/// </summary>
public CharacterSheet BuildSheet()
{
if (!HasLiveData())
return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() };
var props = CurrentPlayerProperties();
int level = props.GetInt(0x19u);
long totalXp = props.GetInt64(1u);
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
var xp = ComputeLevelXp(level, totalXp);
int skillCredits = props.GetInt(0x18u, props.GetInt(0xC0u, props.GetInt(0xB5u)));
return new CharacterSheet
{
Name = CharacterName(),
Level = level,
Gender = CharacterIdentityText.GenderDisplayName(
props.GetInt(CharacterIdentityText.GenderPropertyId)),
Heritage = CharacterIdentityText.HeritageGroupDisplayName(
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)),
PkStatus = PkStatusText(props.GetInt(134u, 0)),
TotalXp = totalXp,
XpToNextLevel = xp.toNext,
XpFraction = xp.fraction,
HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health),
HealthMax = VitalMax(LocalPlayerState.VitalKind.Health),
StaminaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Stamina),
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
Strength = AttrCurrent(LocalPlayerState.AttributeKind.Strength),
Endurance = AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
Coordination = AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
Quickness = AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
UnspentSkillCredits = props.GetInt(0xB5u, skillCredits),
SpecializedSkillCredits = props.GetInt(0xC0u),
SkillCredits = skillCredits,
UnassignedXp = unassignedXp,
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
Skills = BuildLiveCharacterSkills(),
BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u),
};
}
private bool HasLiveData()
{
var props = CurrentPlayerProperties();
return props.Ints.Count > 0
|| props.Int64s.Count > 0
|| _localPlayer.Skills.Count > 0
|| _localPlayer.GetAttribute(LocalPlayerState.AttributeKind.Strength) is not null
|| _localPlayer.Get(LocalPlayerState.VitalKind.Health) is not null;
}
/// <summary>The player's canonical bundle: the live ClientObject's when the
/// player object has arrived (CreateObject merge-upsert), else the
/// PlayerDescription snapshot on LocalPlayerState.</summary>
private PropertyBundle CurrentPlayerProperties()
{
uint guid = _playerGuid();
return guid != 0u && _objects.Get(guid) is { } player
? player.Properties
: _localPlayer.Properties;
}
/// <summary>
/// Load the portal ExperienceTable (0x0E000018), falling back to a
/// type scan for older or odd dat collections. Failures are logged —
/// never silently swallowed — and leave raise costs unavailable (0).
/// </summary>
public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable(
DatCollection dats, Action<string>? log = null)
{
if (dats is null) return null;
try
{
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(0x0E000018u);
if (table is not null) return table;
}
catch (Exception ex)
{
log?.Invoke($"[D.2b-C] ExperienceTable 0x0E000018 read failed ({ex.GetType().Name}: {ex.Message}); trying type scan.");
}
try
{
foreach (uint id in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.ExperienceTable>())
{
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(id);
if (table is not null) return table;
}
}
catch (Exception ex)
{
log?.Invoke($"[D.2b-C] ExperienceTable type scan failed ({ex.GetType().Name}: {ex.Message}); raise costs unavailable.");
}
return null;
}
/// <summary>XP still needed for the next level + fill fraction of the
/// current level band (retail (curbase)/(capbase); CharacterSheet.XpFraction).</summary>
private (long toNext, float fraction) ComputeLevelXp(int level, long totalXp)
{
var levels = ExperienceTable?.Levels;
if (levels is null || level < 0 || level + 1 >= levels.Length)
return (0L, 0f);
long current = ClampToLong(levels[level]);
long next = ClampToLong(levels[level + 1]);
if (next <= current) return (0L, 0f);
long clampedXp = totalXp < current ? current : totalXp > next ? next : totalXp;
long toNext = next - clampedXp;
float fraction = (float)(clampedXp - current) / (next - current);
return (toNext, fraction);
}
/// <summary>Per-attribute/vital raise costs in retail display order
/// (see CharacterSheet.AttributeRaiseCosts). Cost 0 = maxed / unknown.</summary>
private long[] BuildAttributeRaiseCosts(int amount)
{
var xp = ExperienceTable;
return new[]
{
AttributeRaiseCost(LocalPlayerState.AttributeKind.Strength),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Endurance),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Coordination),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Quickness),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Focus),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Self),
VitalRaiseCost(LocalPlayerState.VitalKind.Health),
VitalRaiseCost(LocalPlayerState.VitalKind.Stamina),
VitalRaiseCost(LocalPlayerState.VitalKind.Mana),
};
long AttributeRaiseCost(LocalPlayerState.AttributeKind kind)
{
var attr = _localPlayer.GetAttribute(kind);
return attr is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Attributes, attr.Value.Ranks, attr.Value.Xp, amount);
}
long VitalRaiseCost(LocalPlayerState.VitalKind kind)
{
var vital = _localPlayer.Get(kind);
return vital is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Vitals, vital.Value.Ranks, vital.Value.Xp, amount);
}
}
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
{
var result = new List<CharacterSkill>();
var skillTable = SkillTable;
var xp = ExperienceTable;
foreach (var snapshot in _localPlayer.Skills.Values)
{
var advancement = AdvancementFromStatus(snapshot.Status);
if (advancement == CharacterSkillAdvancementClass.Inactive)
continue;
DatReaderWriter.Types.SkillBase? skillBase = null;
if (skillTable?.Skills is not null)
skillTable.Skills.TryGetValue((DatReaderWriter.Enums.SkillId)snapshot.SkillId, out skillBase);
string? name = skillBase?.Name.Value;
if (string.IsNullOrWhiteSpace(name))
name = $"Skill {snapshot.SkillId}";
uint icon = skillBase?.IconId.DataId ?? 0u;
int trainedCost = skillBase?.TrainedCost ?? 0;
int specializedCost = skillBase?.SpecializedCost ?? 0;
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
result.Add(new CharacterSkill(
snapshot.SkillId,
name,
icon,
advancement,
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
IsUsableUntrained(snapshot.SkillId),
trainedCost,
specializedCost,
raiseCost,
raise10Cost));
}
return result;
}
private static CharacterSkillAdvancementClass AdvancementFromStatus(uint status) => status switch
{
1u => CharacterSkillAdvancementClass.Untrained,
2u => CharacterSkillAdvancementClass.Trained,
3u => CharacterSkillAdvancementClass.Specialized,
_ => CharacterSkillAdvancementClass.Inactive,
};
private static bool IsUsableUntrained(uint skillId) => skillId switch
{
18u or 37u or 38u or 39u or 40u => false,
_ => true,
};
private static long SkillRaiseCost(
DatReaderWriter.DBObjs.ExperienceTable? xp,
CharacterSkillAdvancementClass advancement,
LocalPlayerState.SkillSnapshot skill,
int amount)
{
if (xp is null) return 0L;
uint[] curve = advancement == CharacterSkillAdvancementClass.Specialized
? xp.SpecializedSkills
: xp.TrainedSkills;
return RaiseCostFromXpCurve(curve, skill.Ranks, skill.Xp, amount);
}
/// <summary>Cost to advance <paramref name="amount"/> ranks along a retail
/// cumulative-XP curve: curve[target] xpAlreadySpent, clamped at the
/// curve end (retail GetCostToRaise/GetCostToRaise10 0x0049cb80/0x0049cc70).</summary>
private static long RaiseCostFromXpCurve(uint[]? curve, uint ranks, uint spentXp, int amount)
{
if (curve is null || amount <= 0) return 0L;
long maxIndex = curve.Length - 1L;
if (maxIndex <= ranks) return 0L;
long targetLong = Math.Min((long)ranks + amount, maxIndex);
long targetXp = curve[(int)targetLong];
long cost = targetXp - spentXp;
return cost > 0 ? cost : 0L;
}
private static long ClampToLong(ulong value) =>
value > long.MaxValue ? long.MaxValue : (long)value;
private static string? PkStatusText(int status) => status switch
{
0x2 => "Non-Player Killer",
0x4 => "Player Killer",
0x40 => "Player Killer Lite",
_ => null,
};
private int AttrCurrent(LocalPlayerState.AttributeKind kind) =>
_localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0;
private int VitalCurrent(LocalPlayerState.VitalKind kind) =>
_localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0;
private int VitalMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
// ── Raise-request flow ─────────────────────────────────────────────────
/// <summary>
/// Send a raise/train action to the server and, when a send delegate
/// fired, optimistically apply the local effect so the sheet stays
/// current during the round trip. The next server snapshot remains
/// authoritative (a rejected raise is corrected by the property echo —
/// pending/rollback ledger tracked as a follow-up issue).
/// </summary>
public void HandleRaiseRequest(CharacterStatController.RaiseRequest request)
{
if (request.Cost <= 0) return;
if (_canSendRaise is not null && !_canSendRaise()) return;
bool sent = false;
switch (request.Kind)
{
case CharacterStatController.RaiseTargetKind.Attribute:
if (_sendRaiseAttribute is not null)
{
_sendRaiseAttribute(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.Vital:
if (_sendRaiseVital is not null)
{
_sendRaiseVital(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.Skill:
if (_sendRaiseSkill is not null)
{
_sendRaiseSkill(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.TrainSkill:
if (_sendTrainSkill is not null && request.Cost <= uint.MaxValue)
{
_sendTrainSkill(request.StatId, (uint)request.Cost);
sent = true;
}
break;
}
if (sent)
ApplyLocalRaise(request);
}
private void ApplyLocalRaise(CharacterStatController.RaiseRequest request)
{
uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount;
ulong cost = (ulong)request.Cost;
switch (request.Kind)
{
case CharacterStatController.RaiseTargetKind.Attribute:
if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.Vital:
if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.Skill:
if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.TrainSkill:
if (_localPlayer.ApplySkillTraining(request.StatId))
SpendSkillCredits(request.Cost);
break;
}
}
private void SpendUnassignedExperience(long cost)
{
if (cost <= 0) return;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid) is { } player)
{
long current = player.Properties.GetInt64(UnassignedXpPropertyId);
if (current <= 0) return;
_objects.UpdateInt64Property(guid, UnassignedXpPropertyId,
current > cost ? current - cost : 0L);
return;
}
_localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost);
}
private void SpendSkillCredits(long cost)
{
if (cost <= 0) return;
int debit = cost > int.MaxValue ? int.MaxValue : (int)cost;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid) is { } player)
{
foreach (uint propertyId in SkillCreditPropertyIds)
{
if (!player.Properties.Ints.TryGetValue(propertyId, out int current))
continue;
_objects.UpdateIntProperty(guid, propertyId,
current > debit ? current - debit : 0);
return;
}
return;
}
foreach (uint propertyId in SkillCreditPropertyIds)
{
if (_localPlayer.DebitIntProperty(propertyId, debit))
return;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -209,6 +209,12 @@ public sealed class ChatWindowController
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
c.Transcript.DatFont = datFont;
c.Transcript.Font = debugFont;
// The imported Type-12 element may inherit HJustify=Center from its dat
// prototype, which makes UiText use the static one-line label path. The
// chat transcript must always use the scrollable multi-line path so it
// caches layout for mouse selection and Ctrl+C.
c.Transcript.Centered = false;
c.Transcript.RightAligned = false;
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);

View file

@ -85,6 +85,9 @@ public static class DatWidgetFactory
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
};
e.DatElementId = info.Id;
e.SetStateCursors(info.StateCursors);
// Propagate position + size (pixel-exact from the dat).
e.Left = info.X;
e.Top = info.Y;
@ -151,6 +154,7 @@ public static class DatWidgetFactory
{
var m = new UiMeter
{
ElementId = info.Id,
SpriteResolve = resolve,
DatFont = datFont,
};
@ -290,6 +294,7 @@ public static class DatWidgetFactory
var t = new UiText
{
ElementId = info.Id,
BackgroundSprite = bg,
SpriteResolve = resolve,
Centered = centered,

View file

@ -96,6 +96,12 @@ public sealed class ElementInfo
/// </summary>
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
/// <summary>
/// Cursor per state: state name to (RenderSurface file id, hotspot).
/// The "" key represents DirectState, mirroring <see cref="StateMedia"/>.
/// </summary>
public Dictionary<string, UiCursorMedia> StateCursors = new();
/// <summary>
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
@ -160,8 +166,8 @@ public static class ElementReader
/// (derived placement, not the base prototype's geometry).
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
/// entries override (or add) per state name key.
/// <see cref="ElementInfo.StateMedia"/> and <see cref="ElementInfo.StateCursors"/>:
/// base entries are the default; derived entries override (or add) per state name key.
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
@ -218,6 +224,9 @@ public static class ElementReader
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
foreach (var kv in derived.StateMedia)
m.StateMedia[kv.Key] = kv.Value;
m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors);
foreach (var kv in derived.StateCursors)
m.StateCursors[kv.Key] = kv.Value;
return m;
}
}

View file

@ -22,6 +22,7 @@ public sealed class InventoryController : IItemListDragHandler
public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%")
public const uint BurdenCaptionId = 0x100001D7u; // "Burden"
public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>"
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
@ -45,6 +46,7 @@ public sealed class InventoryController : IItemListDragHandler
private readonly Func<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<int?> _strength;
private readonly Func<string>? _ownerName;
private readonly UiItemList? _contentsGrid;
private readonly UiItemList? _containerList;
@ -60,6 +62,7 @@ public sealed class InventoryController : IItemListDragHandler
private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
private readonly ItemInteractionController? _itemInteraction;
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
private const uint EncumbranceValProperty = 5u; // total carried burden
@ -71,21 +74,28 @@ public sealed class InventoryController : IItemListDragHandler
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
Func<string>? ownerName,
UiDatFont? datFont,
uint contentsEmptySprite,
uint sideBagEmptySprite,
uint mainPackEmptySprite,
Action<uint>? sendUse,
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer)
Action<uint, uint, int>? sendPutItemInContainer,
ItemInteractionController? itemInteraction,
Action? onClose)
{
_objects = objects;
_playerGuid = playerGuid;
_iconIds = iconIds;
_strength = strength;
_ownerName = ownerName;
_sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_itemInteraction = itemInteraction;
WindowChromeController.BindCloseButton(layout, onClose);
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
_containerList = layout.FindElement(ContainerListId) as UiItemList;
@ -142,6 +152,7 @@ public sealed class InventoryController : IItemListDragHandler
// Captions: drive each host UiText directly with the known string (the caption
// elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail
// (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.)
AttachCaption(layout.FindElement(TitleTextId), () => "Inventory of " + OwnerName(), datFont);
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
@ -151,6 +162,8 @@ public sealed class InventoryController : IItemListDragHandler
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
if (_itemInteraction is not null)
_itemInteraction.StateChanged += OnInteractionStateChanged;
Populate();
}
@ -162,19 +175,23 @@ public sealed class InventoryController : IItemListDragHandler
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
UiDatFont? datFont,
Func<string>? ownerName = null,
uint contentsEmptySprite = 0u,
uint sideBagEmptySprite = 0u,
uint mainPackEmptySprite = 0u,
Action<uint>? sendUse = null,
Action<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
Action<uint, uint, int>? sendPutItemInContainer = null,
ItemInteractionController? itemInteraction = null,
Action? onClose = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer);
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
private void OnInteractionStateChanged() => ApplyIndicators();
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
private bool Concerns(ClientObject o)
@ -200,7 +217,7 @@ public sealed class InventoryController : IItemListDragHandler
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
bool isBag = IsBag(item);
if (isBag) AddCell(_containerList, guid, isContainer: true);
}
@ -210,7 +227,7 @@ public sealed class InventoryController : IItemListDragHandler
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
bool isBag = IsBag(item);
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
}
@ -248,7 +265,12 @@ public sealed class InventoryController : IItemListDragHandler
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
main.Clicked = () => OpenContainer(p);
main.Clicked = () =>
{
if (_itemInteraction?.AcquireSelfTarget() == true) return;
OpenContainer(p);
};
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
_topContainer.AddItem(main);
}
@ -261,6 +283,11 @@ public sealed class InventoryController : IItemListDragHandler
/// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default
/// sentinel is 0; once the main pack is explicitly opened, <c>_openContainer</c> holds the player
/// guid instead — both resolve here to the same main-pack container, so the paths are equivalent.</summary>
private static bool IsBag(ClientObject item) =>
item.ContainerTypeHint != 0u
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0;
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
@ -276,6 +303,7 @@ public sealed class InventoryController : IItemListDragHandler
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
cell.DragRejectSprite = 0x060011F8u; // red circle
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
else cell.Clicked = () => SelectItem(guid);
list.AddItem(cell);
@ -405,7 +433,8 @@ public sealed class InventoryController : IItemListDragHandler
{
var cell = list.GetItem(i);
if (cell is null) continue;
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem;
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource;
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
}
}
@ -417,6 +446,12 @@ public sealed class InventoryController : IItemListDragHandler
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
}
private string OwnerName()
{
var name = _ownerName?.Invoke();
return string.IsNullOrWhiteSpace(name) ? "Player" : name;
}
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
{
if (host is null) return;
@ -487,5 +522,7 @@ public sealed class InventoryController : IItemListDragHandler
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
if (_itemInteraction is not null)
_itemInteraction.StateChanged -= OnInteractionStateChanged;
}
}

View file

@ -394,19 +394,27 @@ public static class LayoutImporter
/// <summary>
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
/// <c>info.StateMedia[name]</c> and extract the font DID from property 0x1A
/// <c>info.StateMedia[name]</c>, read any <see cref="MediaDescCursor"/> into
/// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
/// </summary>
private static void ReadState(StateDesc sd, string name, ElementInfo info)
{
// Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars)
// are intentionally skipped — cursor behavior is Plan 2.
bool imageRead = false;
foreach (var m in sd.Media)
{
if (m is MediaDescImage img && img.File != 0)
if (!imageRead && m is MediaDescImage img && img.File != 0)
{
info.StateMedia[name] = (img.File, (int)img.DrawMode);
break;
imageRead = true;
}
if (m is MediaDescCursor cursor && cursor.File != 0)
{
info.StateCursors[name] = new UiCursorMedia(
cursor.File,
checked((int)cursor.XHotspot),
checked((int)cursor.YHotspot));
}
}

View file

@ -75,6 +75,7 @@ public sealed class PaperdollController : IItemListDragHandler
private readonly Func<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
private readonly ItemInteractionController? _itemInteraction;
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
@ -85,9 +86,10 @@ public sealed class PaperdollController : IItemListDragHandler
private PaperdollController(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
uint emptySlotSprite, UiDatFont? datFont)
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction)
{
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
_itemInteraction = itemInteraction;
for (int i = 0; i < SlotMap.Length; i++)
{
@ -97,6 +99,13 @@ public sealed class PaperdollController : IItemListDragHandler
list.Cell.SourceKind = ItemDragSource.Equipment;
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
list.Cell.UseTargetGuidProvider = _playerGuid;
list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
list.Cell.DoubleClicked = () =>
{
if (list.Cell.ItemId != 0)
_itemInteraction?.ActivateItem(list.Cell.ItemId);
};
// 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot
// silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle.
// Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring
@ -140,8 +149,9 @@ public sealed class PaperdollController : IItemListDragHandler
public static PaperdollController Bind(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
uint emptySlotSprite = 0u, UiDatFont? datFont = null)
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont);
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
ItemInteractionController? itemInteraction = null)
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont, itemInteraction);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
@ -209,7 +219,7 @@ public sealed class PaperdollController : IItemListDragHandler
{
var item = _objects.Get(payload.ObjId);
if (item is null) return;
EquipMask wieldMask = item.ValidLocations & MaskFor(targetList);
EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList));
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);

View file

@ -0,0 +1,108 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Mounts an imported retail window's content into the shared 8-piece beveled
/// chrome (<see cref="UiNineSlicePanel"/>) and optionally registers it with
/// the <see cref="UiRoot"/> window manager. This is THE mount recipe every
/// retail window follows: frame sized content + 2×border, content inset by
/// the border with Draggable/Resizable off (the frame owns move/resize).
///
/// <para>The vitals/chat/toolbar/inventory windows still inline this recipe
/// in <c>GameWindow</c> (pre-extraction); new windows must mount through this
/// helper instead of copying the block again — see
/// docs/research/2026-07-02-ui-architecture-review.md (theme T1).</para>
/// </summary>
public static class RetailWindowFrame
{
/// <summary>Per-window placement + behavior knobs. Null-valued knobs keep
/// the widget defaults (<see cref="UiElement"/> / <see cref="UiNineSlicePanel"/>).</summary>
public sealed record Options
{
/// <summary>Initial window position (screen px).</summary>
public float Left { get; init; }
public float Top { get; init; }
/// <summary>Minimum frame size; null = lock to content + 2×border (non-shrinkable).</summary>
public float? MinWidth { get; init; }
public float? MinHeight { get; init; }
/// <summary>Maximum frame height while resizing; null = unbounded.</summary>
public float? MaxHeight { get; init; }
/// <summary>Allow horizontal resize (frame default: true).</summary>
public bool ResizeX { get; init; } = true;
/// <summary>Restrict which edges start a resize; null = all edges.</summary>
public ResizeEdges? ResizableEdges { get; init; }
/// <summary>Window translucency (retail SetDefaultOpacity analog).</summary>
public float Opacity { get; init; } = 1f;
/// <summary>Start shown or hidden (hidden windows toggle via the window manager).</summary>
public bool Visible { get; init; } = true;
/// <summary>How the content tracks the frame when it resizes.</summary>
public AnchorEdges ContentAnchors { get; init; } =
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
/// <summary>Force the content's ClickThrough; null = leave as imported.</summary>
public bool? ContentClickThrough { get; init; }
/// <summary>Register the frame under this name in the UiRoot window
/// manager (<see cref="WindowNames"/>); null = unmanaged window.</summary>
public string? WindowName { get; init; }
}
/// <summary>
/// Wrap <paramref name="content"/> (sized by the importer) in the beveled
/// chrome, add it to <paramref name="root"/>, and register it when
/// <see cref="Options.WindowName"/> is set. Returns the frame.
/// </summary>
public static UiNineSlicePanel Mount(
UiRoot root,
UiElement content,
Func<uint, (uint handle, int w, int h)> resolveChrome,
Options options)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(resolveChrome);
ArgumentNullException.ThrowIfNull(options);
const int border = RetailChromeSprites.Border;
float contentW = content.Width;
float contentH = content.Height;
var frame = new UiNineSlicePanel(resolveChrome)
{
Left = options.Left,
Top = options.Top,
Width = contentW + 2 * border,
Height = contentH + 2 * border,
MinWidth = options.MinWidth ?? contentW + 2 * border,
MinHeight = options.MinHeight ?? contentH + 2 * border,
ResizeX = options.ResizeX,
Opacity = options.Opacity,
Visible = options.Visible,
};
if (options.MaxHeight is { } maxHeight) frame.MaxHeight = maxHeight;
if (options.ResizableEdges is { } edges) frame.ResizableEdges = edges;
content.Left = border;
content.Top = border;
content.Width = contentW;
content.Height = contentH;
content.Anchors = options.ContentAnchors;
if (options.ContentClickThrough is { } clickThrough) content.ClickThrough = clickThrough;
content.Draggable = false; // the frame owns window move/resize
content.Resizable = false;
frame.AddChild(content);
root.AddChild(frame);
if (options.WindowName is { } name)
root.RegisterWindow(name, frame);
return frame;
}
}

View file

@ -54,8 +54,15 @@ public sealed class ToolbarController : IItemListDragHandler
private static readonly uint[] CombatIndicatorIds =
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private const uint CharacterButtonId = 0x10000199u;
private const uint InventoryButtonId = 0x100001B1u;
private const string ButtonClosedState = "Normal";
private const string ButtonOpenState = "Highlight";
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
private readonly UiButton? _characterButton;
private readonly UiButton? _inventoryButton;
private readonly ClientObjectTable _repo;
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
@ -64,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler
private bool _storeLoaded;
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
@ -87,6 +95,7 @@ public sealed class ToolbarController : IItemListDragHandler
uint[]? peaceDigits,
uint[]? warDigits,
uint[]? emptyDigits,
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
@ -97,6 +106,7 @@ public sealed class ToolbarController : IItemListDragHandler
_peaceDigits = peaceDigits;
_warDigits = warDigits;
_emptyDigits = emptyDigits;
_itemInteraction = itemInteraction;
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
@ -120,6 +130,9 @@ public sealed class ToolbarController : IItemListDragHandler
for (int i = 0; i < CombatIndicatorIds.Length; i++)
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
foreach (var id in HiddenIds)
if (layout.FindElement(id) is { } e) e.Visible = false;
@ -201,16 +214,44 @@ public sealed class ToolbarController : IItemListDragHandler
uint[]? peaceDigits = null,
uint[]? warDigits = null,
uint[]? emptyDigits = null,
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits,
peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut);
c.Populate();
return c;
}
/// <summary>Wire the retail status-bar launch buttons to top-level window toggles.</summary>
public void BindWindowToggles(Action? toggleInventory, Action? toggleCharacter)
{
if (_inventoryButton is not null)
{
if (_itemInteraction is not null)
_inventoryButton.UseTargetGuidProvider = () => _itemInteraction.PlayerGuid;
_inventoryButton.OnClick = () =>
{
if (_itemInteraction?.AcquireSelfTarget() == true) return;
toggleInventory?.Invoke();
};
}
if (_characterButton is not null)
_characterButton.OnClick = toggleCharacter;
}
public void SetInventoryOpen(bool open) => SetButtonOpen(_inventoryButton, open);
public void SetCharacterOpen(bool open) => SetButtonOpen(_characterButton, open);
private static void SetButtonOpen(UiButton? button, bool open)
{
if (button is not null)
button.ActiveState = open ? ButtonOpenState : ButtonClosedState;
}
/// <summary>
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
/// each shortcut entry that has a resolved item in the repository.
@ -320,7 +361,12 @@ public sealed class ToolbarController : IItemListDragHandler
list.Cell.Clicked = () =>
{
if (list.Cell.ItemId != 0)
_useItem(list.Cell.ItemId);
{
if (_itemInteraction is not null)
_itemInteraction.ActivateItem(list.Cell.ItemId);
else
_useItem(list.Cell.ItemId);
}
};
}

View file

@ -51,6 +51,8 @@ public sealed class UiDatElement : UiElement
/// Falls back to DirectState if the named state is absent.</summary>
public string ActiveState { get; set; } = "";
public override string ActiveCursorStateName => ActiveState;
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>

View file

@ -0,0 +1,54 @@
using System;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared bindings for imported retail window chrome. The same max/min button id is
/// reused by several LayoutDesc windows; per-window controllers decide what the click
/// means for their window.
/// </summary>
internal static class WindowChromeController
{
public const uint MaxMinButtonId = 0x1000046Fu;
public const uint InventoryCloseButtonId = 0x100001D2u;
public const uint CharacterCloseButtonId = 0x1000022Au;
public static int BindCloseButton(ImportedLayout layout, Action? onClose)
{
if (onClose is null)
return 0;
return BindCloseButton(layout.Root, onClose);
}
private static int BindCloseButton(UiElement node, Action onClose)
{
int count = TryBindCloseButton(node, onClose) ? 1 : 0;
foreach (var child in node.Children)
count += BindCloseButton(child, onClose);
return count;
}
private static bool TryBindCloseButton(UiElement element, Action onClose)
{
switch (element)
{
case UiButton button when IsCloseButtonId(button.ElementId):
button.OnClick = onClose;
return true;
case UiDatElement datElement when IsCloseButtonId(datElement.ElementId):
datElement.ClickThrough = false;
datElement.CapturesPointerDrag = true;
datElement.OnClick = onClose;
return true;
default:
return false;
}
}
private static bool IsCloseButtonId(uint id) =>
id is MaxMinButtonId or InventoryCloseButtonId or CharacterCloseButtonId;
}

View file

@ -0,0 +1,30 @@
namespace AcDream.App.UI;
/// <summary>Retail global cursor enum metadata used outside LayoutDesc widget states.</summary>
internal static class RetailCursorCatalog
{
public const uint CursorEnumTable = 6;
// ClientUISystem::UpdateCursorState (0x00564630):
// TARGET_MODE_USE_TARGET -> enum 0x27 when no SmartBox target is found,
// enum 0x28 when ItemHolder::IsTargetCompatibleWithTargetingObject returns true,
// enum 0x29 when it returns false. UIElementManager::SetCursor is called
// with hotspot (14,14) for all three.
public static bool TryGetGlobalCursor(CursorFeedbackKind kind, out RetailCursorSpec spec)
{
spec = kind switch
{
CursorFeedbackKind.TargetPending => new RetailCursorSpec(0x27u, 14, 14),
CursorFeedbackKind.TargetValid => new RetailCursorSpec(0x28u, 14, 14),
CursorFeedbackKind.TargetInvalid => new RetailCursorSpec(0x29u, 14, 14),
_ => default,
};
return spec.IsValid;
}
}
internal readonly record struct RetailCursorSpec(uint EnumId, int HotspotX, int HotspotY)
{
public bool IsValid => EnumId != 0;
}

View file

@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using AcDream.Core.Items;
namespace AcDream.App.UI.Testing;
/// <summary>
/// Snapshot row for the live retained-mode retail UI tree. Coordinates are
/// absolute root/screen pixels.
/// </summary>
public sealed record RetailUiProbeElement(
int Index,
int Depth,
string Path,
string TypeName,
string? Name,
uint EventId,
uint DatElementId,
float X,
float Y,
float Width,
float Height,
bool Visible,
bool Enabled,
uint ItemId,
int SlotIndex,
ItemDragSource? SourceKind)
{
public int CenterX => (int)MathF.Round(X + Width * 0.5f);
public int CenterY => (int)MathF.Round(Y + Height * 0.5f);
}
public sealed record RetailUiProbeAssertion(bool Success, string Message);
/// <summary>
/// Test/diagnostic harness for the retail-style UI. It drives <see cref="UiRoot"/>
/// mouse events, never panel controllers directly, so automated checks exercise
/// the same click, double-click, and drag-drop path as a player.
/// </summary>
public sealed class RetailUiAutomationProbe
{
private readonly UiRoot _root;
private readonly ClientObjectTable _objects;
private readonly Action<string>? _log;
private long _nowMs = 1;
public RetailUiAutomationProbe(
UiRoot root,
ClientObjectTable objects,
Action<string>? log = null)
{
_root = root ?? throw new ArgumentNullException(nameof(root));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_log = log;
}
public IReadOnlyList<RetailUiProbeElement> Snapshot(bool visibleOnly = true)
{
var rows = new List<RetailUiProbeElement>();
Walk(_root, "root", depth: 0, ancestorsVisible: true, visibleOnly, rows);
return rows;
}
public string DumpText(bool visibleOnly = true)
{
var sb = new StringBuilder();
foreach (var e in Snapshot(visibleOnly))
{
sb.Append('[').Append(e.Index.ToString(CultureInfo.InvariantCulture)).Append("] ");
sb.Append(new string(' ', Math.Max(0, e.Depth) * 2));
sb.Append(e.TypeName);
if (e.DatElementId != 0) sb.Append(" dat=0x").Append(e.DatElementId.ToString("X8", CultureInfo.InvariantCulture));
if (e.EventId != 0) sb.Append(" event=0x").Append(e.EventId.ToString("X8", CultureInfo.InvariantCulture));
if (!string.IsNullOrEmpty(e.Name)) sb.Append(" name=").Append(e.Name);
sb.Append(" rect=(").Append(F(e.X)).Append(',').Append(F(e.Y)).Append(',')
.Append(F(e.Width)).Append(',').Append(F(e.Height)).Append(')');
if (!e.Visible) sb.Append(" hidden");
if (!e.Enabled) sb.Append(" disabled");
if (e.ItemId != 0)
{
sb.Append(" item=0x").Append(e.ItemId.ToString("X8", CultureInfo.InvariantCulture));
sb.Append(" slot=").Append(e.SlotIndex.ToString(CultureInfo.InvariantCulture));
if (e.SourceKind is { } source) sb.Append(" source=").Append(source);
}
sb.Append(" path=").Append(e.Path);
sb.AppendLine();
}
return sb.ToString();
}
public RetailUiProbeElement? FindByDatElementId(uint datElementId, bool visibleOnly = true)
{
foreach (var e in Snapshot(visibleOnly))
if (e.DatElementId == datElementId && HasArea(e))
return e;
return null;
}
public IReadOnlyList<RetailUiProbeElement> FindAllByDatElementId(uint datElementId, bool visibleOnly = true)
{
var matches = new List<RetailUiProbeElement>();
foreach (var e in Snapshot(visibleOnly))
if (e.DatElementId == datElementId)
matches.Add(e);
return matches;
}
public RetailUiProbeElement? FindByItemId(
uint itemGuid,
ItemDragSource? sourceKind = null,
bool visibleOnly = true)
{
if (itemGuid == 0) return null;
foreach (var e in Snapshot(visibleOnly))
{
if (e.ItemId != itemGuid) continue;
if (sourceKind is { } source && e.SourceKind != source) continue;
if (!HasArea(e)) continue;
return e;
}
return null;
}
public bool ClickElement(uint datElementId)
{
var target = FindByDatElementId(datElementId);
if (target is null) return Fail($"element 0x{datElementId:X8} not found");
ClickAt(target.CenterX, target.CenterY);
return true;
}
public bool ClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
{
var target = FindByItemId(itemGuid, sourceKind);
if (target is null) return Fail($"item 0x{itemGuid:X8} not found");
ClickAt(target.CenterX, target.CenterY);
return true;
}
public bool DoubleClickItem(uint itemGuid, ItemDragSource? sourceKind = null)
{
var target = FindByItemId(itemGuid, sourceKind);
if (target is null) return Fail($"item 0x{itemGuid:X8} not found");
ClickAt(target.CenterX, target.CenterY);
Advance(80);
ClickAt(target.CenterX, target.CenterY);
return true;
}
public bool DragItemToElement(uint itemGuid, uint datElementId, ItemDragSource? sourceKind = null)
{
var source = FindByItemId(itemGuid, sourceKind);
if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found");
var target = FindByDatElementId(datElementId);
if (target is null) return Fail($"drop target element 0x{datElementId:X8} not found");
DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY);
return true;
}
public bool DragItemToItem(uint sourceGuid, uint targetGuid, ItemDragSource? sourceKind = null)
{
var source = FindByItemId(sourceGuid, sourceKind);
if (source is null) return Fail($"drag source item 0x{sourceGuid:X8} not found");
var target = FindByItemId(targetGuid);
if (target is null) return Fail($"drop target item 0x{targetGuid:X8} not found");
DragAt(source.CenterX, source.CenterY, target.CenterX, target.CenterY);
return true;
}
public bool DragItemOutside(uint itemGuid, int x, int y, ItemDragSource? sourceKind = null)
{
var source = FindByItemId(itemGuid, sourceKind);
if (source is null) return Fail($"drag source item 0x{itemGuid:X8} not found");
DragAt(source.CenterX, source.CenterY, x, y);
return true;
}
public RetailUiProbeAssertion AssertItem(
uint itemGuid,
EquipMask? equippedLocation = null,
uint? containerId = null,
int? slot = null)
{
var item = _objects.Get(itemGuid);
if (item is null)
return Result(false, $"item 0x{itemGuid:X8} missing from object table");
if (equippedLocation is { } equip && item.CurrentlyEquippedLocation != equip)
return Result(false,
$"item 0x{itemGuid:X8} equip expected 0x{((uint)equip):X8}, got 0x{((uint)item.CurrentlyEquippedLocation):X8}");
if (containerId is { } container && item.ContainerId != container)
return Result(false,
$"item 0x{itemGuid:X8} container expected 0x{container:X8}, got 0x{item.ContainerId:X8}");
if (slot is { } slotValue && item.ContainerSlot != slotValue)
return Result(false,
$"item 0x{itemGuid:X8} slot expected {slotValue}, got {item.ContainerSlot}");
return Result(true, $"item 0x{itemGuid:X8} matched object-table assertion");
}
private void Walk(
UiElement element,
string path,
int depth,
bool ancestorsVisible,
bool visibleOnly,
List<RetailUiProbeElement> rows)
{
bool effectiveVisible = ancestorsVisible && element.Visible;
if (visibleOnly && !effectiveVisible) return;
if (element is UiItemList list)
list.LayoutCells();
var pos = element.ScreenPosition;
uint itemId = 0;
int slotIndex = -1;
ItemDragSource? sourceKind = null;
if (element is UiItemSlot slot)
{
itemId = slot.ItemId;
slotIndex = slot.SlotIndex;
sourceKind = slot.SourceKind;
}
rows.Add(new RetailUiProbeElement(
rows.Count,
depth,
path,
element.GetType().Name,
element.Name,
element.EventId,
element.DatElementId,
pos.X,
pos.Y,
element.Width,
element.Height,
effectiveVisible,
element.Enabled,
itemId,
slotIndex,
sourceKind));
var children = element.Children;
for (int i = 0; i < children.Count; i++)
{
var child = children[i];
var childPath = $"{path}/{child.GetType().Name}[{i}]";
if (child.DatElementId != 0)
childPath += $"#0x{child.DatElementId:X8}";
Walk(child, childPath, depth + 1, effectiveVisible, visibleOnly, rows);
}
}
private void ClickAt(int x, int y)
{
Advance(16);
_root.OnMouseMove(x, y);
_root.OnMouseDown(UiMouseButton.Left, x, y);
Advance(50);
_root.OnMouseUp(UiMouseButton.Left, x, y);
Advance(16);
}
private void DragAt(int startX, int startY, int endX, int endY)
{
Advance(16);
_root.OnMouseMove(startX, startY);
_root.OnMouseDown(UiMouseButton.Left, startX, startY);
Advance(16);
_root.OnMouseMove(startX + 5, startY + 5);
Advance(16);
_root.OnMouseMove(endX, endY);
Advance(16);
_root.OnMouseUp(UiMouseButton.Left, endX, endY);
Advance(16);
}
private void Advance(int ms)
{
_nowMs += ms;
_root.Tick(ms / 1000.0, _nowMs);
}
private static bool HasArea(RetailUiProbeElement element)
=> element.Width > 0f && element.Height > 0f;
private bool Fail(string message)
{
_log?.Invoke(message);
return false;
}
private RetailUiProbeAssertion Result(bool success, string message)
{
_log?.Invoke(message);
return new RetailUiProbeAssertion(success, message);
}
private static string F(float value)
=> value.ToString("0.##", CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,348 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using AcDream.Core.Items;
namespace AcDream.App.UI.Testing;
/// <summary>
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
/// It is intended for local diagnostic launches, not for gameplay. Commands
/// execute on render ticks and use the same <see cref="UiRoot"/> input path as
/// physical mouse input.
/// </summary>
public sealed class RetailUiAutomationScriptRunner
{
private readonly RetailUiAutomationProbe _probe;
private readonly Action<string> _log;
private readonly List<ScriptCommand> _commands = new();
private readonly bool _dumpOnStart;
private readonly string? _loadError;
private int _index;
private int _activeIndex = -1;
private long _elapsedMs;
private long _commandStartMs;
private bool _started;
private bool _completed;
public RetailUiAutomationScriptRunner(
RetailUiAutomationProbe probe,
string? scriptPath,
bool dumpOnStart,
Action<string>? log = null)
{
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
_log = log ?? (_ => { });
_dumpOnStart = dumpOnStart;
if (!string.IsNullOrWhiteSpace(scriptPath))
{
try
{
int lineNumber = 0;
foreach (var raw in File.ReadAllLines(scriptPath))
{
lineNumber++;
var line = StripComment(raw).Trim();
if (line.Length == 0) continue;
_commands.Add(new ScriptCommand(lineNumber, line, Split(line)));
}
}
catch (Exception ex)
{
_loadError = $"failed to load UI probe script '{scriptPath}': {ex.Message}";
}
}
}
public bool Completed => _completed;
public void Tick(double deltaSeconds)
{
if (_completed) return;
long addMs = Math.Max(1, (long)Math.Round(deltaSeconds * 1000.0));
_elapsedMs += addMs;
if (!_started)
{
_started = true;
if (_loadError is not null)
{
_log(_loadError);
_completed = true;
return;
}
if (_dumpOnStart)
_log(_probe.DumpText());
if (_commands.Count == 0)
{
_completed = true;
return;
}
_log($"running {_commands.Count} UI probe command(s)");
}
int guard = 0;
while (!_completed && _index < _commands.Count && guard++ < 8)
{
if (_activeIndex != _index)
{
_activeIndex = _index;
_commandStartMs = _elapsedMs;
}
var command = _commands[_index];
bool finished = Execute(command);
if (!finished) break;
_index++;
_activeIndex = -1;
}
if (_index >= _commands.Count && !_completed)
{
_completed = true;
_log("UI probe script complete");
}
}
private bool Execute(ScriptCommand command)
{
var p = command.Parts;
if (p.Length == 0) return true;
string verb = p[0].ToLowerInvariant();
return verb switch
{
"dump" => DoDump(),
"click" => DoClick(command),
"doubleclick" => DoDoubleClick(command),
"drag" => DoDrag(command),
"wait" => DoWait(command),
"sleep" => DoSleep(command),
"assert" => DoAssert(command),
_ => Stop(command, $"unknown command '{p[0]}'"),
};
}
private bool DoDump()
{
_log(_probe.DumpText());
return true;
}
private bool DoClick(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 3) return Stop(command, "usage: click element <datId> | click item <guid> [source]");
string target = p[1].ToLowerInvariant();
if (target == "element")
{
if (!TryParseUInt(p[2], out uint datId)) return Stop(command, $"bad element id '{p[2]}'");
return _probe.ClickElement(datId) || Stop(command, "click element failed");
}
if (target == "item")
{
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
return _probe.ClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "click item failed");
}
return Stop(command, "usage: click element <datId> | click item <guid> [source]");
}
private bool DoDoubleClick(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 3 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
return Stop(command, "usage: doubleclick item <guid> [source]");
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
return _probe.DoubleClickItem(itemGuid, ParseSource(p, 3)) || Stop(command, "doubleclick item failed");
}
private bool DoDrag(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
return Stop(command, "usage: drag item <guid> element <datId> | drag item <guid> item <guid> | drag item <guid> outside <x> <y>");
if (!TryParseUInt(p[2], out uint sourceGuid)) return Stop(command, $"bad item guid '{p[2]}'");
string target = p[3].ToLowerInvariant();
if (target == "element")
{
if (!TryParseUInt(p[4], out uint datId)) return Stop(command, $"bad element id '{p[4]}'");
return _probe.DragItemToElement(sourceGuid, datId, ParseSource(p, 5))
|| Stop(command, "drag item to element failed");
}
if (target == "item")
{
if (!TryParseUInt(p[4], out uint targetGuid)) return Stop(command, $"bad target item guid '{p[4]}'");
return _probe.DragItemToItem(sourceGuid, targetGuid, ParseSource(p, 5))
|| Stop(command, "drag item to item failed");
}
if (target == "outside")
{
if (p.Length < 6) return Stop(command, "usage: drag item <guid> outside <x> <y> [source]");
if (!TryParseInt(p[4], out int x)) return Stop(command, $"bad x coordinate '{p[4]}'");
if (!TryParseInt(p[5], out int y)) return Stop(command, $"bad y coordinate '{p[5]}'");
return _probe.DragItemOutside(sourceGuid, x, y, ParseSource(p, 6))
|| Stop(command, "drag item outside failed");
}
return Stop(command, "usage: drag item <guid> element/item/outside ...");
}
private bool DoWait(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 2) return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
string target = p[1].ToLowerInvariant();
if (target == "item")
{
if (p.Length < 3 || !TryParseUInt(p[2], out uint itemGuid)) return Stop(command, "usage: wait item <guid> [source] [timeoutMs]");
if (_probe.FindByItemId(itemGuid, ParseSource(p, 3)) is not null) return true;
return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"item 0x{itemGuid:X8}");
}
if (target == "element")
{
if (p.Length < 3 || !TryParseUInt(p[2], out uint datId)) return Stop(command, "usage: wait element <datId> [timeoutMs]");
if (_probe.FindByDatElementId(datId) is not null) return true;
return WaitOrTimeout(command, TimeoutMs(p, 3, 10000), $"element 0x{datId:X8}");
}
if (target == "ms")
return DoSleep(command);
return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
}
private bool DoSleep(ScriptCommand command)
{
var p = command.Parts;
string value = p.Length >= 3 && string.Equals(p[0], "wait", StringComparison.OrdinalIgnoreCase) ? p[2]
: p.Length >= 2 ? p[1] : "";
if (!TryParseInt(value, out int ms) || ms < 0)
return Stop(command, "usage: sleep <milliseconds> | wait ms <milliseconds>");
return _elapsedMs - _commandStartMs >= ms;
}
private bool DoAssert(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 5 || !string.Equals(p[1], "item", StringComparison.OrdinalIgnoreCase))
return Stop(command, "usage: assert item <guid> equip|container|slot <value>");
if (!TryParseUInt(p[2], out uint itemGuid)) return Stop(command, $"bad item guid '{p[2]}'");
string kind = p[3].ToLowerInvariant();
RetailUiProbeAssertion result;
if (kind == "equip")
{
if (!TryParseUInt(p[4], out uint mask)) return Stop(command, $"bad equip mask '{p[4]}'");
result = _probe.AssertItem(itemGuid, equippedLocation: (EquipMask)mask);
}
else if (kind == "container")
{
if (!TryParseUInt(p[4], out uint containerId)) return Stop(command, $"bad container id '{p[4]}'");
result = _probe.AssertItem(itemGuid, containerId: containerId);
}
else if (kind == "slot")
{
if (!TryParseInt(p[4], out int slot)) return Stop(command, $"bad slot '{p[4]}'");
result = _probe.AssertItem(itemGuid, slot: slot);
}
else
{
return Stop(command, "usage: assert item <guid> equip|container|slot <value>");
}
return result.Success || Stop(command, result.Message);
}
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
{
if (_elapsedMs - _commandStartMs <= timeoutMs) return false;
return Stop(command, $"timed out waiting for {label}");
}
private bool Stop(ScriptCommand command, string message)
{
_log($"line {command.LineNumber}: {message}; command: {command.Text}");
_completed = true;
return false;
}
private static string StripComment(string line)
{
int hash = line.IndexOf('#');
int slashes = line.IndexOf("//", StringComparison.Ordinal);
int cut = -1;
if (hash >= 0) cut = hash;
if (slashes >= 0) cut = cut >= 0 ? Math.Min(cut, slashes) : slashes;
return cut >= 0 ? line[..cut] : line;
}
private static string[] Split(string line)
=> line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static ItemDragSource? ParseSource(string[] parts, int start)
{
for (int i = start; i < parts.Length; i++)
if (TryParseSource(parts[i], out var source))
return source;
return null;
}
private static bool TryParseSource(string value, out ItemDragSource source)
{
switch (value.ToLowerInvariant())
{
case "inventory":
case "pack":
case "backpack":
source = ItemDragSource.Inventory;
return true;
case "shortcut":
case "shortcutbar":
case "toolbar":
source = ItemDragSource.ShortcutBar;
return true;
case "equipment":
case "equip":
case "paperdoll":
source = ItemDragSource.Equipment;
return true;
case "ground":
source = ItemDragSource.Ground;
return true;
default:
source = default;
return false;
}
}
private static int TimeoutMs(string[] parts, int start, int defaultMs)
{
for (int i = start; i < parts.Length; i++)
if (TryParseInt(parts[i], out int ms) && ms >= 0)
return ms;
return defaultMs;
}
private static bool TryParseUInt(string value, out uint parsed)
{
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return uint.TryParse(value[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed);
return uint.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
}
private static bool TryParseInt(string value, out int parsed)
=> int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
private readonly record struct ScriptCommand(int LineNumber, string Text, string[] Parts);
}

View file

@ -38,6 +38,9 @@ public sealed class UiButton : UiElement
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>.</summary>
public uint ElementId => _info.Id;
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
public string? Label { get; set; }
@ -60,6 +63,8 @@ public sealed class UiButton : UiElement
/// </summary>
public string ActiveState { get; set; } = "";
public override string ActiveCursorStateName => ActiveState;
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>

View file

@ -9,6 +9,12 @@ namespace AcDream.App.UI;
[System.Flags]
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
/// <summary>Retail dat cursor media attached to a UI state.</summary>
public readonly record struct UiCursorMedia(uint File, int HotspotX, int HotspotY)
{
public bool IsValid => File != 0;
}
/// <summary>
/// Base class for every UI widget in the retained-mode tree.
///
@ -40,9 +46,56 @@ public abstract class UiElement
/// </summary>
public uint EventId { get; internal set; }
/// <summary>
/// Dat LayoutDesc element id for widgets imported from retail UI data.
/// Zero for runtime-created helper widgets. This is intentionally separate
/// from <see cref="EventId"/>: event ids are runtime-local, while this id is
/// stable across retail layout dumps, decomp references, and UI probes.
/// </summary>
public uint DatElementId { get; internal set; }
/// <summary>Human-readable name for debugging / FindByName.</summary>
public string? Name { get; init; }
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
public IReadOnlyDictionary<string, UiCursorMedia> StateCursors => _stateCursors;
/// <summary>Active state name used for cursor media. Stateful dat widgets override this.</summary>
public virtual string ActiveCursorStateName => "";
public void SetStateCursors(IReadOnlyDictionary<string, UiCursorMedia> cursors)
{
_stateCursors.Clear();
foreach (var kv in cursors)
{
if (kv.Value.IsValid)
_stateCursors[kv.Key] = kv.Value;
}
}
public UiCursorMedia ActiveCursor()
=> CursorForState(ActiveCursorStateName, allowFallback: true);
public UiCursorMedia CursorForState(string stateName, bool allowFallback = true)
{
if (!string.IsNullOrEmpty(stateName)
&& _stateCursors.TryGetValue(stateName, out var named)
&& named.IsValid)
return named;
if (!allowFallback)
return default;
if (_stateCursors.TryGetValue("", out var direct) && direct.IsValid)
return direct;
if (_stateCursors.TryGetValue("Normal", out var normal) && normal.IsValid)
return normal;
return default;
}
// ── Geometry ────────────────────────────────────────────────────────
/// <summary>X in the parent's local pixel space.</summary>
public float Left { get; set; }
@ -130,6 +183,14 @@ public abstract class UiElement
/// self-driven interior drag such as text selection). Default false.</summary>
public virtual bool HandlesClick => false;
/// <summary>
/// Optional game-object target used by item-use cursor/target mode when this UI
/// element represents a target that is not its visible item icon. Example:
/// the status-bar backpack button and paperdoll both mean "self" for a
/// health-kit-style use-with-target action.
/// </summary>
public Func<uint>? UseTargetGuidProvider { get; set; }
/// <summary>Minimum size enforced while resizing.</summary>
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;

View file

@ -114,6 +114,9 @@ public sealed class UiHost : System.IDisposable
/// <summary>Hide a registered window; returns false if the name is unknown.</summary>
public bool HideWindow(string name) => Root.HideWindow(name);
/// <summary>Return the current visibility of a registered window.</summary>
public bool IsWindowVisible(string name) => Root.IsWindowVisible(name);
/// <summary>Toggle a registered window's visibility; returns the new IsVisible.</summary>
public bool ToggleWindow(string name) => Root.ToggleWindow(name);

View file

@ -3,6 +3,16 @@ using System.Collections.Generic;
namespace AcDream.App.UI;
/// <summary>Cell order for a multi-column <see cref="UiItemList"/>.</summary>
public enum UiItemListFlow
{
/// <summary>Retail listbox bit 0 set: index advances across columns, then down rows.</summary>
RowMajor,
/// <summary>Retail listbox bit 0 clear: index advances down rows, then across columns.</summary>
ColumnMajor,
}
/// <summary>
/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031).
/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the
@ -82,9 +92,16 @@ public sealed class UiItemList : UiElement
LayoutCells();
}
/// <summary>Grid columns (row-major). 1 = single column. Ignored in fill mode.</summary>
/// <summary>Grid columns. 1 = single column. Ignored in fill mode.</summary>
public int Columns { get; set; } = 1;
/// <summary>
/// Multi-column list flow. Retail UIElement_ListBox::UpdateLayout uses bit 0 of
/// m_bitField: set = row-major, clear = column-major. Default preserves the
/// existing toolbar/grid behavior; panel controllers select retail-specific flow.
/// </summary>
public UiItemListFlow Flow { get; set; } = UiItemListFlow.RowMajor;
/// <summary>Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes
/// to the whole list (the toolbar single-slot legacy). Set &gt;0 (with CellHeight) for a grid.</summary>
public float CellWidth { get; set; }
@ -107,8 +124,25 @@ public sealed class UiItemList : UiElement
return (col * cellW, row * cellH);
}
/// <summary>
/// Pixel offset matching retail UIElement_ListBox::CalculateColumn/CalculateRow:
/// row-major when bit 0 is set, column-major when it is clear.
/// </summary>
internal static (float x, float y) CellOffset(
int index, int columns, int cellCount, UiItemListFlow flow, float cellW, float cellH)
{
int cols = columns < 1 ? 1 : columns;
if (flow == UiItemListFlow.ColumnMajor)
{
int rows = Math.Max(1, RowCount(cellCount, cols));
int col = index / rows, row = index % rows;
return (col * cellW, row * cellH);
}
return CellOffset(index, cols, cellW, cellH);
}
/// <summary>Position every cell per the current mode: fill (CellWidth&lt;=0) sizes the single
/// cell to the list; grid (CellWidth&gt;0) tiles cells row-major, offset by the scroll position
/// cell to the list; grid (CellWidth&gt;0) tiles cells by <see cref="Flow"/>, offset by the scroll position
/// with whole-row vertical clipping (cells fully outside the view are hidden).</summary>
internal void LayoutCells()
{
@ -137,7 +171,7 @@ public sealed class UiItemList : UiElement
for (int i = 0; i < _cells.Count; i++)
{
var (x, baseY) = CellOffset(i, cols, CellWidth, CellHeight);
var (x, baseY) = CellOffset(i, cols, _cells.Count, Flow, CellWidth, CellHeight);
float top = baseY - scrollY;
var cell = _cells[i];
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;

View file

@ -171,6 +171,9 @@ public sealed class UiItemSlot : UiElement
/// a bound slot. Wired by <c>ToolbarController</c> to the use-item callback.</summary>
public Action? Clicked { get; set; }
/// <summary>Invoked by <see cref="OnEvent"/> when a double-click lands on a bound slot.</summary>
public Action? DoubleClicked { get; set; }
/// <inheritdoc/>
public override bool OnEvent(in UiEvent e)
{
@ -184,6 +187,9 @@ public sealed class UiItemSlot : UiElement
case UiEventType.Click:
Clicked?.Invoke();
return true;
case UiEventType.DoubleClick:
DoubleClicked?.Invoke();
return true;
case UiEventType.DragBegin:
// Notify the source list's handler so it can lift (remove + wire) — retail

View file

@ -16,6 +16,8 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class UiMeter : UiElement
{
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
public uint ElementId { get; set; }
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
public Func<float?> Fill { get; set; } = () => 0f;

View file

@ -44,7 +44,11 @@ public class UiPanel : UiElement
}
else if (BackgroundColor.W > 0f)
{
ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
// Panel fills are backgrounds. Draw them through the sprite/fill
// bucket so children that render as sprites/dat-font glyphs stay on
// top in painter order; DrawRect flushes after sprites and would
// cover text/icons.
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
}
if (BorderColor.W > 0f && BorderThickness > 0f)

View file

@ -71,6 +71,34 @@ public sealed class UiRoot : UiElement
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
public bool IsWindowMoveActive => _windowDragTarget is not null;
public ResizeEdges ActiveResizeEdges => _resizeTarget is not null ? _resizeEdges : ResizeEdges.None;
public ResizeEdges HoverResizeEdges
{
get
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
return window is { Resizable: true }
? HitEdges(window, MouseX, MouseY, ResizeGrip)
: ResizeEdges.None;
}
}
public bool HoverWindowMove
{
get
{
var target = Pick(MouseX, MouseY);
var window = FindWindow(target);
if (target is null || window is not { Draggable: true })
return false;
if (HoverResizeEdges != ResizeEdges.None)
return false;
return !target.IsDragSource
&& !target.CapturesPointerDrag
&& !target.HandlesClick;
}
}
private (uint tex, int w, int h)? _dragGhost;
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
@ -79,12 +107,16 @@ public sealed class UiRoot : UiElement
private bool _dragCandidate;
private UiElement? _windowDragTarget;
private int _windowDragOffX, _windowDragOffY;
private UiElement? _lastClickTarget;
private long _lastClickMs;
private int _lastClickX, _lastClickY;
private UiElement? _resizeTarget;
private ResizeEdges _resizeEdges;
private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH;
private int _resizeMouseX, _resizeMouseY;
private const int ResizeGrip = 5; // px proximity to an edge to start a resize
private const int DragDistanceThreshold = 3; // pixels, retail-observed
private const int DoubleClickDelayMs = 500;
// Hover / tooltip tracking.
private UiElement? _hoverWidget;
@ -106,6 +138,9 @@ public sealed class UiRoot : UiElement
/// <summary>Raised on scroll fall-through (world zoom, etc.).</summary>
public event Action<int /*dy*/>? WorldScrollFallThrough;
/// <summary>Raised when a drag is released over no UI element.</summary>
public event Action<object /*payload*/, int /*x*/, int /*y*/>? DragReleasedOutsideUi;
private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child)
@ -371,6 +406,24 @@ public sealed class UiRoot : UiElement
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in click);
long now = _nowMs != 0 ? _nowMs : Environment.TickCount64;
bool isDoubleClick =
ReferenceEquals(Captured, _lastClickTarget)
&& now - _lastClickMs <= DoubleClickDelayMs
&& Math.Abs(x - _lastClickX) <= DragDistanceThreshold
&& Math.Abs(y - _lastClickY) <= DragDistanceThreshold;
if (isDoubleClick)
{
var dbl = new UiEvent(Captured.EventId, Captured, UiEventType.DoubleClick,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in dbl);
}
_lastClickTarget = Captured;
_lastClickMs = now;
_lastClickX = x;
_lastClickY = y;
}
else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y))
{
@ -504,6 +557,10 @@ public sealed class UiRoot : UiElement
return true;
}
/// <summary>Return the current visibility of a registered window.</summary>
public bool IsWindowVisible(string name)
=> _windows.TryGetValue(name, out var w) && w.Visible;
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
/// Returns the new IsVisible state (false for an unknown name).</summary>
public bool ToggleWindow(string name)
@ -575,7 +632,10 @@ public sealed class UiRoot : UiElement
Data1: (int)lx, Data2: (int)ly, Payload: DragPayload);
t.OnEvent(in e);
}
// else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail).
else if (DragPayload is { } payload)
{
DragReleasedOutsideUi?.Invoke(payload, x, y);
}
DragSource = null;
DragPayload = null;
_dragGhost = null;

View file

@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Simple vertical viewport for controller-built row lists. It shares the same
/// pixel scroll model as chat text and item grids, and clips whole rows because
/// the UI renderer does not have a scissor stack yet.
/// </summary>
public sealed class UiScrollablePanel : UiPanel
{
private readonly Dictionary<UiElement, float> _baseTops = new(ReferenceEqualityComparer.Instance);
public UiScrollable Scroll { get; } = new();
public int LineHeight { get; set; } = 16;
public int ContentHeight { get; private set; }
public UiScrollablePanel()
{
BackgroundColor = Vector4.Zero;
BorderColor = Vector4.Zero;
}
public override void AddChild(UiElement child)
{
base.AddChild(child);
Track(child);
}
public override bool RemoveChild(UiElement child)
{
bool removed = base.RemoveChild(child);
if (removed)
{
_baseTops.Remove(child);
RecomputeContentHeight();
}
return removed;
}
public void ClearContent()
{
foreach (var child in Children.ToArray())
RemoveChild(child);
_baseTops.Clear();
ContentHeight = 0;
Scroll.SetScrollY(0);
}
internal void LayoutScrollableChildren()
{
Scroll.LineHeight = Math.Max(1, LineHeight);
Scroll.ContentHeight = ContentHeight;
Scroll.ViewHeight = Math.Max(0, (int)MathF.Floor(Height));
Scroll.SetScrollY(Scroll.ScrollY);
foreach (var child in Children)
{
if (!_baseTops.TryGetValue(child, out float baseTop))
continue;
float top = baseTop - Scroll.ScrollY;
child.Top = top;
child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f;
}
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Scroll)
{
Scroll.ScrollByLines(-e.Data0);
LayoutScrollableChildren();
return true;
}
return base.OnEvent(e);
}
protected override void OnDraw(UiRenderContext ctx)
{
LayoutScrollableChildren();
base.OnDraw(ctx);
}
private void Track(UiElement child)
{
child.Anchors = AnchorEdges.None;
_baseTops[child] = child.Top;
RecomputeContentHeight();
}
private void RecomputeContentHeight()
{
float bottom = 0f;
foreach (var child in Children)
{
if (!_baseTops.TryGetValue(child, out float top))
continue;
bottom = MathF.Max(bottom, top + child.Height);
}
ContentHeight = (int)MathF.Ceiling(bottom);
Scroll.SetScrollY(Scroll.ScrollY);
}
}

View file

@ -23,6 +23,9 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class UiText : UiElement
{
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
public uint ElementId { get; set; }
/// <summary>One display line: pre-formatted text + its colour.</summary>
public readonly record struct Line(string Text, Vector4 Color);