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

@ -98,7 +98,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 3. Documented approximation (AP) — 68 rows
## 3. Documented approximation (AP) — 70 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -171,6 +171,8 @@ accepted-divergence entries (#96, #49, #50).
| AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render |
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573309597 |
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
| AP-73 | **Character raises apply optimistically with no pending/rollback ledger** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits unassigned XP / skill credits locally (via eventful store APIs). Retail's local-predict behavior for raises is unverified; item moves in acdream reconcile via RecordPending/Confirm/Rollback but raises do not (**#163**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`HandleRaiseRequest`/`ApplyLocalRaise`) | The next server property echo (PlayerDescription / private stat updates) is authoritative and overwrites every optimistic value; ACE confirms affordable raises, and the affordability gate runs client-side first (`Cost <= 0` refuses). | A server-rejected raise (XP race, validation) leaves ranks/XP/credits wrong on the sheet until the next full property refresh — retire by porting the ledger (#163) or cdb-tracing retail's raise flow. | unverified — candidate cdb trace: retail raise-button send path |
---
## 4. Temporary stopgap (TS) — 32 rows

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);

View file

@ -239,9 +239,12 @@ public static class GameEventWiring
{
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is null) return;
uint wielderGuid = p.Value.WielderGuid != 0u
? p.Value.WielderGuid
: (playerGuid?.Invoke() ?? 0u);
items.MoveItem(
p.Value.ItemGuid,
newContainerId: p.Value.WielderGuid,
newContainerId: wielderGuid,
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
});
@ -249,7 +252,11 @@ public static class GameEventWiring
{
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
if (p is null) return;
items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement);
items.MoveItem(
p.Value.ItemGuid,
p.Value.ContainerGuid,
newSlot: (int)p.Value.Placement,
containerTypeHint: p.Value.ContainerType);
items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move
});
@ -262,9 +269,12 @@ public static class GameEventWiring
{
var p = GameEvents.ParseViewContents(e.Payload.Span);
if (p is null) return;
var guids = new uint[p.Value.Items.Count];
for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid;
items.ReplaceContents(p.Value.ContainerGuid, guids);
var entries = new ContainerContentEntry[p.Value.Items.Count];
for (int i = 0; i < entries.Length; i++)
entries[i] = new ContainerContentEntry(
p.Value.Items[i].Guid,
p.Value.Items[i].ContainerType);
items.ReplaceContents(p.Value.ContainerGuid, entries);
});
// B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
@ -273,7 +283,11 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e =>
{
var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u);
if (guid is not null)
{
items.MoveItem(guid.Value, newContainerId: 0u);
items.ConfirmMove(guid.Value);
}
});
// B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move.
@ -284,8 +298,12 @@ public static class GameEventWiring
var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span);
if (p is null) return;
// B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot.
if (!items.RollbackMove(p.Value.ItemGuid))
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)");
var item = items.Get(p.Value.ItemGuid);
string itemInfo = item is null
? "unknown"
: $"'{item.Name}' valid=0x{(uint)item.ValidLocations:X8} equip=0x{(uint)item.CurrentlyEquippedLocation:X8} priority=0x{item.Priority:X8} container=0x{item.ContainerId:X8} wielder=0x{item.WielderId:X8}";
bool rolledBack = items.RollbackMove(p.Value.ItemGuid);
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} rolledBack={rolledBack} item={itemInfo}");
});
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
@ -366,6 +384,8 @@ public static class GameEventWiring
if (localPlayer is not null)
{
localPlayer.OnProperties(p.Value.Properties);
foreach (var attr in p.Value.Attributes)
{
if (attr.Current is uint cur)
@ -408,14 +428,12 @@ public static class GameEventWiring
// component; .Ranks is XP-bought additions. Their sum is
// the closest we get to ACE's CreatureSkill.Current short
// of porting the full Aug/Multiplier/Vitae chain.
if (onSkillsUpdated is not null)
if (localPlayer is not null || onSkillsUpdated is not null)
{
int runSkill = -1;
int jumpSkill = -1;
foreach (var s in p.Value.Skills)
{
if (s.SkillId != 22u && s.SkillId != 24u) continue;
// K-fix13: total = AttributeFormula(skill, attrs)
// + InitLevel (s.Init from wire)
// + Ranks (s.Ranks from wire)
@ -428,6 +446,19 @@ public static class GameEventWiring
uint formulaBonus = resolveSkillFormulaBonus is not null
? resolveSkillFormulaBonus(s.SkillId, attrCurrents)
: 0u;
localPlayer?.OnSkillUpdate(
skillId: s.SkillId,
ranks: s.Ranks,
status: s.Status,
xp: s.Xp,
init: s.Init,
resistance: s.Resistance,
lastUsed: s.LastUsed,
formulaBonus: formulaBonus);
if (s.SkillId != 22u && s.SkillId != 24u) continue;
int total = (int)(formulaBonus + s.Init + s.Ranks);
if (s.SkillId == 24u) runSkill = total;
else if (s.SkillId == 22u) jumpSkill = total;
@ -437,7 +468,7 @@ public static class GameEventWiring
$"vitals: PD-skill id={s.SkillId} init={s.Init} ranks={s.Ranks} formulaBonus={formulaBonus} total={total}");
}
if (runSkill >= 0 || jumpSkill >= 0)
onSkillsUpdated(runSkill, jumpSkill);
onSkillsUpdated?.Invoke(runSkill, jumpSkill);
}
// Issue #7 — enchantment block: feed each entry into the
@ -463,8 +494,21 @@ public static class GameEventWiring
// actual weenie data via ObjectTableWiring. (Previously this seeded
// stubs with WeenieClassId = ContainerType, a misuse — ContainerType
// is a 0/1/2 container-kind discriminator, not a weenie class id.)
foreach (var inv in p.Value.Inventory)
items.RecordMembership(inv.Guid);
uint ownerGuid = playerGuid?.Invoke() ?? 0u;
if (ownerGuid != 0u)
{
var entries = new ContainerContentEntry[p.Value.Inventory.Count];
for (int i = 0; i < entries.Length; i++)
entries[i] = new ContainerContentEntry(
p.Value.Inventory[i].Guid,
p.Value.Inventory[i].ContainerType);
items.ReplaceContents(ownerGuid, entries);
}
else
{
foreach (var inv in p.Value.Inventory)
items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType);
}
foreach (var eq in p.Value.Equipped)
items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation);

View file

@ -146,6 +146,7 @@ public static class CreateObject
// publish it.
uint? Useability = null,
float? UseRadius = null,
uint? TargetType = null,
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the
// WeenieHeader optional tail. IconOverlayId is gated by
// WeenieHeaderFlag.IconOverlay (0x40000000) in weenieFlags;
@ -645,6 +646,7 @@ public static class CreateObject
// SAFE because IconComposer early-returns on id==0 per layer.
uint? useability = null;
float? useRadius = null;
uint? targetType = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
@ -707,7 +709,7 @@ public static class CreateObject
if ((weenieFlags & 0x00080000u) != 0) // TargetType u32
{
if (body.Length - pos < 4) throw new FormatException("trunc TargetType");
pos += 4;
targetType = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
{
@ -850,7 +852,7 @@ public static class CreateObject
physicsState, objectDescriptionFlags,
friction, elasticity,
IconId: iconId,
Useability: useability, UseRadius: useRadius,
Useability: useability, UseRadius: useRadius, TargetType: targetType,
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
UiEffects: uiEffects,
WeenieClassId: weenieClassId,

View file

@ -336,11 +336,14 @@ public static class GameEvents
public static WieldObject? ParseWieldObject(ReadOnlySpan<byte> payload)
{
if (payload.Length < 12) return null;
if (payload.Length < 8) return null;
uint wielderGuid = payload.Length >= 12
? BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))
: 0u;
return new WieldObject(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
wielderGuid);
}
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.

View file

@ -81,5 +81,7 @@ public static class ObjectTableWiring
ContainersCapacity: s.ContainersCapacity,
Structure: s.Structure,
MaxStructure: s.MaxStructure,
Workmanship: s.Workmanship);
Workmanship: s.Workmanship,
Useability: s.Useability,
TargetType: s.TargetType);
}

View file

@ -82,6 +82,7 @@ public sealed class WorldSession : IDisposable
// server publishes it for non-useable display entities.
uint? Useability = null,
float? UseRadius = null,
uint? TargetType = null,
// D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering.
uint IconId = 0,
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended
@ -807,6 +808,7 @@ public sealed class WorldSession : IDisposable
parsed.Value.Elasticity,
parsed.Value.Useability,
parsed.Value.UseRadius,
parsed.Value.TargetType,
parsed.Value.IconId,
parsed.Value.IconOverlayId,
parsed.Value.IconUnderlayId,
@ -1209,6 +1211,34 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <summary>Send retail RaiseAttribute (0x0045).</summary>
public void SendRaiseAttribute(uint attrId, ulong xpSpent)
{
uint seq = NextGameActionSequence();
SendGameAction(CharacterActions.BuildRaiseAttribute(seq, attrId, xpSpent));
}
/// <summary>Send retail RaiseVital (0x0044).</summary>
public void SendRaiseVital(uint vitalId, ulong xpSpent)
{
uint seq = NextGameActionSequence();
SendGameAction(CharacterActions.BuildRaiseVital(seq, vitalId, xpSpent));
}
/// <summary>Send retail RaiseSkill (0x0046).</summary>
public void SendRaiseSkill(uint skillId, ulong xpSpent)
{
uint seq = NextGameActionSequence();
SendGameAction(CharacterActions.BuildRaiseSkill(seq, skillId, xpSpent));
}
/// <summary>Send retail TrainSkill (0x0047).</summary>
public void SendTrainSkill(uint skillId, uint credits)
{
uint seq = NextGameActionSequence();
SendGameAction(CharacterActions.BuildTrainSkill(seq, skillId, credits));
}
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
@ -1255,9 +1285,17 @@ public sealed class WorldSession : IDisposable
SendGameAction(InteractRequests.BuildUse(seq, targetGuid));
}
/// <summary>Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement
/// <summary>Send UseWithTarget (0x0035) - use a source item on an acquired target.
/// Retail: CM_Inventory::Event_UseWithTargetEvent.</summary>
public void SendUseWithTarget(uint sourceGuid, uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid));
}
/// <summary>Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
/// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer.</summary>
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.</summary>
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
{
uint seq = NextGameActionSequence();

View file

@ -117,9 +117,23 @@ public sealed class PropertyBundle
public Dictionary<uint, uint> InstanceIds { get; } = new();
public int GetInt (uint k, int def = 0) => Ints.TryGetValue(k, out var v) ? v : def;
public long GetInt64 (uint k, long def = 0) => Int64s.TryGetValue(k, out var v) ? v : def;
public bool GetBool (uint k, bool def = false) => Bools.TryGetValue(k, out var v) ? v : def;
public double GetFloat (uint k, double def = 0) => Floats.TryGetValue(k, out var v) ? v : def;
public string GetString(uint k, string def = "") => Strings.TryGetValue(k, out var v) ? v : def;
public PropertyBundle Clone()
{
var copy = new PropertyBundle();
foreach (var kv in Ints) copy.Ints[kv.Key] = kv.Value;
foreach (var kv in Int64s) copy.Int64s[kv.Key] = kv.Value;
foreach (var kv in Bools) copy.Bools[kv.Key] = kv.Value;
foreach (var kv in Floats) copy.Floats[kv.Key] = kv.Value;
foreach (var kv in Strings) copy.Strings[kv.Key] = kv.Value;
foreach (var kv in DataIds) copy.DataIds[kv.Key] = kv.Value;
foreach (var kv in InstanceIds) copy.InstanceIds[kv.Key] = kv.Value;
return copy;
}
}
/// <summary>
@ -150,12 +164,20 @@ public sealed class ClientObject
public int Value { get; set; } // pyreals
public uint ContainerId { get; set; } // parent container ObjectId, or 0
public int ContainerSlot { get; set; } = -1;
/// <summary>
/// Retail <c>ContentProfile.m_uContainerProperties</c> hint from
/// PlayerDescription/ViewContents: 0 = loose item list, nonzero = container list.
/// Kept separate from <see cref="Type"/> because CreateObject owns real item data.
/// </summary>
public uint ContainerTypeHint { get; set; }
public bool Attuned { get; set; }
public bool Bonded { get; set; }
public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded
public int ItemsCapacity { get; set; } // main-pack slots (containers)
public int ContainersCapacity{ get; set; } // side-pack slots (containers)
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
public uint? Useability { get; set; } // ITEM_USEABLE from PublicWeenieDesc
public uint? TargetType { get; set; } // ITEM_TYPE mask for targeted-use compatibility
public int Structure { get; set; } // charges/uses remaining
public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
@ -193,7 +215,52 @@ public readonly record struct WeenieData(
int? ContainersCapacity,
int? Structure,
int? MaxStructure,
float? Workmanship);
float? Workmanship,
uint? Useability = null,
uint? TargetType = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
/// Low 16 bits describe where the source may be used from; high 16 bits
/// describe where the acquired target may be.
/// </summary>
public static class ItemUseability
{
public const uint Undef = 0x0u;
public const uint No = 0x1u;
public const uint Self = 0x2u;
public const uint Wielded = 0x4u;
public const uint Contained = 0x8u;
public const uint Viewed = 0x10u;
public const uint Remote = 0x20u;
public const uint NeverWalk = 0x40u;
public const uint ObjSelf = 0x80u;
public const uint SourceMask = 0x0000FFFFu;
public const uint TargetMask = 0xFFFF0000u;
public static bool IsTargeted(uint useability)
=> (useability & TargetMask) != 0;
public static bool AllowsSelfTarget(uint useability)
=> ((useability >> 16) & Self) != 0;
public static bool AllowsObjectSelfTarget(uint useability)
=> ((useability >> 16) & ObjSelf) != 0;
public static uint SourceFlags(uint useability)
=> useability & SourceMask;
public static uint TargetFlags(uint useability)
=> (useability & TargetMask) >> 16;
public static bool IsDirectUseable(uint useability)
{
uint source = SourceFlags(useability);
return !IsTargeted(useability)
&& (source & ~(No | NeverWalk)) != Undef;
}
}
/// <summary>
/// Container = inventory pack. Hierarchy is strictly 2-deep: character

View file

@ -4,6 +4,12 @@ using System.Collections.Generic;
namespace AcDream.Core.Items;
/// <summary>
/// Ordered container snapshot entry from retail ContentProfile:
/// guid plus m_uContainerProperties.
/// </summary>
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
/// <summary>
/// The client's table of every server object (retail <c>weenie_object_table</c> /
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
@ -69,6 +75,7 @@ public sealed class ClientObjectTable
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
/// <see cref="ClientObject.Effects"/>.</summary>
public const uint UiEffectsPropertyId = 18u;
public const uint CurrentWieldedLocationPropertyId = 10u;
public int ObjectCount => _objects.Count;
public int ContainerCount => _containers.Count;
@ -124,7 +131,7 @@ public sealed class ClientObjectTable
/// so RollbackMove restores full pre-move state through this method alone.
/// </summary>
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None)
EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
@ -132,6 +139,7 @@ public sealed class ClientObjectTable
item.ContainerId = newContainerId;
item.ContainerSlot = newSlot;
item.CurrentlyEquippedLocation = newEquipLocation;
if (containerTypeHint is { } hint) item.ContainerTypeHint = hint;
Reindex(item, oldContainer);
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
return true;
@ -306,6 +314,23 @@ public sealed class ClientObjectTable
if (!_objects.TryGetValue(itemId, out var item)) return false;
item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
if (propertyId == CurrentWieldedLocationPropertyId)
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>
/// Apply a single PropertyInt64 update to an object's bundle and fire
/// ObjectUpdated so bound widgets refresh. Int64 counterpart of
/// <see cref="UpdateIntProperty"/> (used by the character sheet's
/// optimistic unassigned-XP debit; also the hook for future
/// PrivateUpdatePropertyInt64 parsing). False if the object is unknown.
/// </summary>
public bool UpdateInt64Property(uint itemId, uint propertyId, long value)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
item.Properties.Int64s[propertyId] = value;
ObjectUpdated?.Invoke(item);
return true;
}
@ -359,6 +384,8 @@ public sealed class ClientObjectTable
if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl;
if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl;
if (d.Priority is { } pr) obj.Priority = pr;
if (d.Useability is { } use) obj.Useability = use;
if (d.TargetType is { } targetType) obj.TargetType = targetType;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
if (d.Structure is { } st) obj.Structure = st;
@ -377,7 +404,7 @@ public sealed class ClientObjectTable
/// icon/name/type/effects — that data comes from CreateObject.
/// </summary>
public ClientObject RecordMembership(uint guid, uint containerId = 0,
EquipMask equip = EquipMask.None)
EquipMask equip = EquipMask.None, uint? containerTypeHint = null)
{
bool existed = _objects.TryGetValue(guid, out var obj);
if (!existed || obj is null) // keep: satisfies nullable flow analysis
@ -386,8 +413,16 @@ public sealed class ClientObjectTable
_objects[guid] = obj;
}
uint oldContainer = obj.ContainerId;
if (containerId != 0) obj.ContainerId = containerId;
if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip;
if (containerId != 0)
{
obj.ContainerId = containerId;
obj.CurrentlyEquippedLocation = EquipMask.None;
}
else
{
obj.CurrentlyEquippedLocation = equip;
}
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
return obj;
@ -404,12 +439,21 @@ public sealed class ClientObjectTable
if (!_containerIndex.TryGetValue(obj.ContainerId, out var list))
_containerIndex[obj.ContainerId] = list = new List<uint>();
if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId);
list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b)));
var priorOrder = new Dictionary<uint, int>(list.Count);
for (int i = 0; i < list.Count; i++)
priorOrder[list[i]] = i;
list.Sort((a, b) =>
{
int c = SlotOf(a).CompareTo(SlotOf(b));
return c != 0 ? c : priorOrder[a].CompareTo(priorOrder[b]);
});
}
}
private int SlotOf(uint guid) =>
_objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue;
_objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0
? o.ContainerSlot
: int.MaxValue;
/// <summary>
/// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot.
@ -433,7 +477,26 @@ public sealed class ClientObjectTable
ArgumentNullException.ThrowIfNull(guids);
if (containerId == 0) return;
var keep = new HashSet<uint>(guids);
var entries = new ContainerContentEntry[guids.Count];
for (int i = 0; i < guids.Count; i++)
entries[i] = new ContainerContentEntry(guids[i], 0u);
ReplaceContents(containerId, entries);
}
/// <summary>
/// Replace a container's entire membership with <paramref name="entries"/> in retail order.
/// PlayerDescription and ViewContents both carry ContentProfile entries; the order is the
/// dense list order retail feeds to ACCObjectMaint::ViewObjectContents, while ContainerType
/// chooses the loose-item list versus the side-pack/container list.
/// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<ContainerContentEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (containerId == 0) return;
var keep = new HashSet<uint>();
foreach (var entry in entries)
keep.Add(entry.Guid);
// Detach prior members no longer present (they left the container server-side). GetContents
// returns a snapshot, so mutating the index inside the loop is safe.
@ -442,23 +505,26 @@ public sealed class ClientObjectTable
if (keep.Contains(old)) continue;
if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue;
o.ContainerId = 0;
o.CurrentlyEquippedLocation = EquipMask.None;
Reindex(o, containerId);
ObjectMoved?.Invoke(o, containerId, 0);
}
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
for (int i = 0; i < guids.Count; i++)
for (int i = 0; i < entries.Count; i++)
{
uint g = guids[i];
bool existed = _objects.TryGetValue(g, out var obj);
var entry = entries[i];
bool existed = _objects.TryGetValue(entry.Guid, out var obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = g };
_objects[g] = obj;
obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj;
}
uint oldContainer = obj.ContainerId;
obj.ContainerId = containerId;
obj.ContainerSlot = i;
obj.CurrentlyEquippedLocation = EquipMask.None;
obj.ContainerTypeHint = entry.ContainerType;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj);
else ObjectMoved?.Invoke(obj, oldContainer, containerId);

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
@ -87,10 +88,27 @@ public sealed class LocalPlayerState
/// on primary-attribute state held elsewhere on the cache.</summary>
public readonly record struct VitalSnapshot(uint Ranks, uint Start, uint Xp, uint Current);
/// <summary>Per-skill snapshot from PlayerDescription's CreatureSkill table.</summary>
public readonly record struct SkillSnapshot(
uint SkillId,
uint Ranks,
uint Status,
uint Xp,
uint Init,
uint Resistance,
double LastUsed,
uint FormulaBonus)
{
public uint BaseLevel => FormulaBonus + Init + Ranks;
public uint CurrentLevel => BaseLevel;
}
private VitalSnapshot? _health;
private VitalSnapshot? _stamina;
private VitalSnapshot? _mana;
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
private readonly Dictionary<uint, SkillSnapshot> _skills = new();
private PropertyBundle _properties = new();
private readonly Spellbook? _spellbook;
/// <summary>
@ -112,6 +130,9 @@ public sealed class LocalPlayerState
/// only at PlayerDescription / future <c>PrivateUpdateAttribute</c>).</summary>
public event System.Action<AttributeKind>? AttributeChanged;
/// <summary>Fires after player properties or skills from PlayerDescription change.</summary>
public event System.Action? CharacterChanged;
/// <summary>
/// Map a vital-id (across both ID systems) to a <see cref="VitalKind"/>.
/// <list type="bullet">
@ -158,6 +179,16 @@ public sealed class LocalPlayerState
public AttributeSnapshot? GetAttribute(AttributeKind kind) =>
_attrs.TryGetValue(kind, out var a) ? a : null;
/// <summary>Snapshot of the local player's current property bundle.</summary>
public PropertyBundle Properties => _properties;
/// <summary>All known skill snapshots, keyed by SkillId.</summary>
public IReadOnlyDictionary<uint, SkillSnapshot> Skills => _skills;
/// <summary>Snapshot for one skill, or <c>null</c> if it has not arrived yet.</summary>
public SkillSnapshot? GetSkill(uint skillId) =>
_skills.TryGetValue(skillId, out var s) ? s : null;
/// <summary>
/// Compute the buffed max for a vital, using the full retail formula:
/// <c>(vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff</c>
@ -273,6 +304,134 @@ public sealed class LocalPlayerState
AttributeChanged?.Invoke(kind);
}
/// <summary>Replace the local player's top-level property snapshot from PlayerDescription.</summary>
public void OnProperties(PropertyBundle properties)
{
_properties = properties.Clone();
CharacterChanged?.Invoke();
}
/// <summary>Apply or replace one PlayerDescription skill entry.</summary>
public void OnSkillUpdate(
uint skillId,
uint ranks,
uint status,
uint xp,
uint init,
uint resistance,
double lastUsed,
uint formulaBonus)
{
_skills[skillId] = new SkillSnapshot(
skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus);
CharacterChanged?.Invoke();
}
/// <summary>
/// Optimistically apply a successful local attribute-raise action.
/// The next server snapshot remains authoritative; this keeps UI state current
/// during the round trip after sending the retail raise action.
/// </summary>
public bool ApplyAttributeRaise(uint atType, uint amount, ulong xpSpent)
{
if (AttributeIdToKind(atType) is not AttributeKind kind) return false;
if (!_attrs.TryGetValue(kind, out var prev)) return false;
_attrs[kind] = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
AttributeChanged?.Invoke(kind);
return true;
}
/// <summary>Optimistically apply a successful local max-vital raise action.</summary>
public bool ApplyVitalRaise(uint vitalId, uint amount, ulong xpSpent)
{
if (VitalIdToKind(vitalId) is not VitalKind kind) return false;
VitalSnapshot? existing = Get(kind);
if (existing is not VitalSnapshot prev) return false;
var snap = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
switch (kind)
{
case VitalKind.Health: _health = snap; break;
case VitalKind.Stamina: _stamina = snap; break;
case VitalKind.Mana: _mana = snap; break;
}
Changed?.Invoke(kind);
return true;
}
/// <summary>Optimistically promote an untrained skill after a successful TrainSkill action.</summary>
public bool ApplySkillTraining(uint skillId)
{
if (!_skills.TryGetValue(skillId, out var prev)) return false;
if (prev.Status >= 2u) return false;
_skills[skillId] = prev with { Status = 2u };
CharacterChanged?.Invoke();
return true;
}
/// <summary>Optimistically apply a successful local skill-raise action.</summary>
public bool ApplySkillRaise(uint skillId, uint amount, ulong xpSpent)
{
if (!_skills.TryGetValue(skillId, out var prev)) return false;
if (prev.Status < 2u) return false;
_skills[skillId] = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
CharacterChanged?.Invoke();
return true;
}
/// <summary>
/// Optimistically debit an int property in the player's property bundle
/// (clamped at 0), firing <see cref="CharacterChanged"/> so bound UI
/// refreshes. False if the property is absent — callers walk their
/// fallback id chain. The next server snapshot remains authoritative.
/// All local-player property writes go through eventful APIs like this
/// one; writing <see cref="Properties"/> dictionaries directly skips the
/// change event and is a single-owner-state violation.
/// </summary>
public bool DebitIntProperty(uint propertyId, int amount)
{
if (!_properties.Ints.TryGetValue(propertyId, out int current))
return false;
_properties.Ints[propertyId] = current > amount ? current - amount : 0;
CharacterChanged?.Invoke();
return true;
}
/// <summary>
/// Optimistically debit an int64 property (clamped at 0), firing
/// <see cref="CharacterChanged"/>. False if the property is absent or
/// already ≤ 0. The next server snapshot remains authoritative.
/// </summary>
public bool DebitInt64Property(uint propertyId, long amount)
{
if (!_properties.Int64s.TryGetValue(propertyId, out long current) || current <= 0)
return false;
_properties.Int64s[propertyId] = current > amount ? current - amount : 0L;
CharacterChanged?.Invoke();
return true;
}
private static uint SaturatingAdd(uint value, uint delta)
=> uint.MaxValue - value < delta ? uint.MaxValue : value + delta;
private static uint SaturatingAdd(uint value, ulong delta)
=> delta > uint.MaxValue - value ? uint.MaxValue : value + (uint)delta;
// ── Retail attribute contribution ──────────────────────────────────────
//
// Source: ACE Source/ACE.Server/Entity/AttributeFormula.cs +

View file

@ -25,4 +25,20 @@ public class RuntimeOptionsRetailUiTests
Assert.False(opts.RetailUi);
Assert.Null(opts.AcDir);
}
[Fact]
public void Parse_ReadsUiProbeOptions()
{
var env = new Dictionary<string, string?>
{
["ACDREAM_UI_PROBE_DUMP"] = "1",
["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt",
};
var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
Assert.True(opts.UiProbeDump);
Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript);
Assert.True(opts.UiProbeEnabled);
}
}

View file

@ -38,6 +38,9 @@ public sealed class RuntimeOptionsTests
Assert.True(opts.RetailCloseDegrades);
Assert.False(opts.DumpSceneryZ);
Assert.Null(opts.LegacyStreamRadius);
Assert.False(opts.UiProbeDump);
Assert.Null(opts.UiProbeScript);
Assert.False(opts.UiProbeEnabled);
Assert.False(opts.HasLiveCredentials);
}

View file

@ -0,0 +1,155 @@
using AcDream.App.UI;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class CursorFeedbackControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000010u;
private const uint Source = 0x50000020u;
private const uint Target = 0x50000021u;
private const uint HealthKitUseability = 0x000A0008u;
[Fact]
public void DragState_winsOverResizeAndUsesAcceptRejectCursors()
{
var c = new CursorFeedbackController();
var accept = c.Resolve(new CursorFeedbackSnapshot(
DragPayload: new object(),
DragAccept: UiItemSlot.DragAcceptState.Accept,
HoverResizeEdges: ResizeEdges.Right));
var reject = c.Resolve(new CursorFeedbackSnapshot(
DragPayload: new object(),
DragAccept: UiItemSlot.DragAcceptState.Reject,
HoverResizeEdges: ResizeEdges.Right));
Assert.Equal(CursorFeedbackKind.DragAccept, accept.Kind);
Assert.Equal(CursorFeedbackKind.DragReject, reject.Kind);
}
[Theory]
[InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal)]
[InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical)]
[InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse)]
[InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw)]
public void ResizeEdges_chooseExpectedCursor(ResizeEdges edges, CursorFeedbackKind expected)
{
var c = new CursorFeedbackController();
var feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges));
Assert.Equal(expected, feedback.Kind);
}
[Fact]
public void MoveAndTextHover_useDedicatedCursorsWhenNoHigherPriorityState()
{
var c = new CursorFeedbackController();
Assert.Equal(CursorFeedbackKind.WindowMove,
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Kind);
Assert.Equal(CursorFeedbackKind.Text,
c.Resolve(new CursorFeedbackSnapshot(HoverTextEdit: true)).Kind);
}
[Fact]
public void TargetMode_reportsValidInvalidAndPendingTargets()
{
var objects = SeedTargetObjects();
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
var c = new CursorFeedbackController(interaction);
Assert.True(interaction.ActivateItem(Source));
Assert.Equal(CursorFeedbackKind.TargetValid,
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: Player)).Kind);
Assert.Equal(CursorFeedbackKind.TargetInvalid,
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: 0x5000BADu)).Kind);
Assert.Equal(CursorFeedbackKind.TargetInvalid,
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true)).Kind);
Assert.Equal(CursorFeedbackKind.TargetPending,
c.Resolve(new CursorFeedbackSnapshot()).Kind);
}
[Fact]
public void UpdateFromRoot_usesUseTargetGuidProviderBeforeSlotItem()
{
var objects = SeedTargetObjects();
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
var root = new UiRoot { Width = 800, Height = 600 };
var slot = new UiItemSlot
{
Left = 10,
Top = 10,
Width = 32,
Height = 32,
UseTargetGuidProvider = () => Player,
};
var acceptCursor = new UiCursorMedia(0x06009999u, 11, 12);
slot.SetStateCursors(new Dictionary<string, UiCursorMedia>
{
["Drag_rollover_accept"] = acceptCursor,
});
slot.SetItem(Target, iconTexture: 1u);
root.AddChild(slot);
root.OnMouseMove(20, 20);
var c = new CursorFeedbackController(interaction);
Assert.True(interaction.ActivateItem(Source));
var feedback = c.Update(root);
Assert.Equal(CursorFeedbackKind.TargetValid, feedback.Kind);
Assert.Equal(acceptCursor, feedback.Cursor);
}
private static ClientObjectTable SeedTargetObjects()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = Pack,
Name = "Backpack",
Type = ItemType.Container,
ItemsCapacity = 24,
});
objects.MoveItem(Pack, Player, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Source,
Name = "Health Kit",
Type = ItemType.Misc,
Useability = HealthKitUseability,
});
objects.MoveItem(Source, Pack, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Target,
Name = "Target",
Type = ItemType.Misc,
});
objects.MoveItem(Target, Pack, 1);
return objects;
}
}

View file

@ -0,0 +1,303 @@
using AcDream.App.UI;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class ItemInteractionControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000010u;
private const uint HealthKitUseability = 0x000A0008u;
private sealed class Harness
{
public readonly ClientObjectTable Objects = new();
public readonly List<uint> Uses = new();
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<uint> Drops = new();
public readonly List<string> Toasts = new();
public long Now = 1_000;
public Harness()
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Pack,
Name = "Backpack",
Type = ItemType.Container,
ItemsCapacity = 24,
});
Objects.MoveItem(Pack, Player, 0);
Controller = new ItemInteractionController(
Objects,
playerGuid: () => Player,
sendUse: Uses.Add,
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
sendWield: (item, mask) => Wields.Add((item, mask)),
sendDrop: Drops.Add,
nowMs: () => Now,
toast: Toasts.Add);
}
public ItemInteractionController Controller { get; }
public ClientObject AddContained(uint id, Action<ClientObject>? configure = null)
{
var item = new ClientObject
{
ObjectId = id,
Name = $"Item {id:X}",
Type = ItemType.Misc,
IconId = 0x06001234u,
};
configure?.Invoke(item);
Objects.AddOrUpdate(item);
Objects.MoveItem(id, Pack, Objects.GetContents(Pack).Count);
return item;
}
}
[Fact]
public void TargetedItem_entersTargetModeAndMarksPendingSource()
{
var h = new Harness();
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
Assert.True(h.Controller.ActivateItem(0x50000A01u));
Assert.True(h.Controller.IsTargetModeActive);
Assert.True(h.Controller.IsPendingSource(0x50000A01u));
Assert.Empty(h.UseWithTarget);
}
[Fact]
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
{
var h = new Harness();
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
h.Controller.ActivateItem(0x50000A01u);
Assert.True(h.Controller.AcquireSelfTarget());
Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget);
Assert.False(h.Controller.IsTargetModeActive);
}
[Fact]
public void ActivateTargetItemDuringTargetMode_usesClickedItemAsTarget()
{
var h = new Harness();
h.AddContained(0x50000A01u, item => item.Useability = 0x00080008u);
h.AddContained(0x50000A02u);
h.Controller.ActivateItem(0x50000A01u);
Assert.True(h.Controller.ActivateItem(0x50000A02u));
Assert.Equal(new[] { (0x50000A01u, 0x50000A02u) }, h.UseWithTarget);
Assert.False(h.Controller.IsTargetModeActive);
}
[Fact]
public void DirectUseItem_sendsUse()
{
var h = new Harness();
h.AddContained(0x50000A03u, item => item.Useability = ItemUseability.Contained);
Assert.True(h.Controller.ActivateItem(0x50000A03u));
Assert.Equal(new[] { 0x50000A03u }, h.Uses);
}
[Fact]
public void ContainerItem_sendsUseToOpen()
{
var h = new Harness();
h.AddContained(0x50000A04u, item =>
{
item.Type = ItemType.Container;
item.ItemsCapacity = 12;
});
Assert.True(h.Controller.ActivateItem(0x50000A04u));
Assert.Equal(new[] { 0x50000A04u }, h.Uses);
}
[Fact]
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically()
{
var h = new Harness();
h.AddContained(0x50000A05u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = EquipMask.HeadWear;
});
Assert.True(h.Controller.ActivateItem(0x50000A05u));
Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields);
var equipped = h.Objects.Get(0x50000A05u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation);
}
[Fact]
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically()
{
var h = new Harness();
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
h.AddContained(0x50000A15u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = coatMask;
});
Assert.True(h.Controller.ActivateItem(0x50000A15u));
Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields);
var equipped = h.Objects.Get(0x50000A15u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation);
}
[Fact]
public void AutoWearItemWithOverlappingSlotButDifferentPriority_sendsFullMask()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF1u,
Type = ItemType.Clothing,
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
Priority = 0x00000001u,
});
h.Objects.MoveItem(0x50000AF1u, Player, -1, EquipMask.UpperArmWear);
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
h.AddContained(0x50000A16u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = coatMask;
item.Priority = 0x00000002u;
});
Assert.True(h.Controller.ActivateItem(0x50000A16u));
Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields);
Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
}
[Fact]
public void AutoWearItemWithOverlappingSlotAndPriority_sendsNothing()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF2u,
Type = ItemType.Clothing,
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
Priority = 0x00000004u,
});
h.Objects.MoveItem(0x50000AF2u, Player, -1, EquipMask.UpperArmWear);
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
h.AddContained(0x50000A17u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = coatMask;
item.Priority = 0x00000004u;
});
Assert.False(h.Controller.ActivateItem(0x50000A17u));
Assert.Empty(h.Wields);
Assert.Equal(Pack, h.Objects.Get(0x50000A17u)!.ContainerId);
}
[Fact]
public void EquippableItemWithNoFreeSlot_sendsNothing()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF0u,
Type = ItemType.Armor,
CurrentlyEquippedLocation = EquipMask.Shield,
});
h.Objects.MoveItem(0x50000AF0u, Player, -1, EquipMask.Shield);
h.AddContained(0x50000A06u, item =>
{
item.Type = ItemType.Armor;
item.ValidLocations = EquipMask.Shield;
});
Assert.False(h.Controller.ActivateItem(0x50000A06u));
Assert.Empty(h.Wields);
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
}
[Fact]
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
{
var h = new Harness();
h.AddContained(0x50000A07u);
var payload = new ItemDragPayload(
0x50000A07u,
ItemDragSource.Inventory,
SourceSlot: 0,
SourceCell: new UiItemSlot());
Assert.True(h.Controller.DropToWorld(payload));
Assert.Equal(new[] { 0x50000A07u }, h.Drops);
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
}
[Fact]
public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem()
{
var h = new Harness();
h.AddContained(0x50000A08u);
var payload = new ItemDragPayload(
0x50000A08u,
ItemDragSource.ShortcutBar,
SourceSlot: 0,
SourceCell: new UiItemSlot());
Assert.False(h.Controller.DropToWorld(payload));
Assert.Empty(h.Drops);
Assert.Equal(Pack, h.Objects.Get(0x50000A08u)!.ContainerId);
}
[Fact]
public void ActivateItem_appliesRetailUseThrottle()
{
var h = new Harness();
h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained);
h.Controller.ActivateItem(0x50000A09u);
h.Now += 199;
h.Controller.ActivateItem(0x50000A09u);
h.Now += 1;
h.Controller.ActivateItem(0x50000A09u);
Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses);
}
}

View file

@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
public sealed class CharacterLayoutImportProbe
{
private const uint CharacterLayout = 0x2100002Eu;
private static string? DatDir()
{
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(d) ? d : null;
}
[Fact]
public void Selected_attribute_shows_raise_buttons_on_visible_footer_state()
{
var datDir = DatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null);
Assert.NotNull(layout);
CharacterStatController.Bind(layout!, SampleData.SampleCharacter, spriteResolve: _ => (1u, 30, 26));
var rows = new List<UiClickablePanel>();
CollectRows(layout!.Root, rows);
Assert.True(rows.Count >= 9, $"expected at least 9 attribute rows, found {rows.Count}");
rows[4].OnClick!();
var buttons = new List<UiButton>();
CollectButtons(layout.Root, buttons);
Assert.Contains(buttons, b =>
b.ElementId == CharacterStatController.RaiseOneId
&& b.ActiveState == "Normal"
&& IsEffectivelyVisible(b));
Assert.Contains(buttons, b =>
b.ElementId == CharacterStatController.RaiseTenId
&& b.ActiveState == "Normal"
&& IsEffectivelyVisible(b));
}
[Fact]
public void Close_button_resolves_and_invokes_controller_close_callback()
{
var datDir = DatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null);
Assert.NotNull(layout);
var close = layout!.FindElement(WindowChromeController.CharacterCloseButtonId);
Assert.NotNull(close);
Assert.True(close is UiButton or UiDatElement,
$"character close button resolved to {close?.GetType().Name ?? "null"}");
int closes = 0;
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++);
close!.OnEvent(new UiEvent(0u, close, UiEventType.Click));
Assert.Equal(1, closes);
}
private static void CollectRows(UiElement node, List<UiClickablePanel> result)
{
if (node is UiClickablePanel row && row.Height is >= 20f and <= 22f && row.OnClick is not null)
result.Add(row);
foreach (var child in node.Children)
CollectRows(child, result);
}
private static void CollectButtons(UiElement node, List<UiButton> result)
{
if (node is UiButton button
&& (button.ElementId == CharacterStatController.RaiseOneId
|| button.ElementId == CharacterStatController.RaiseTenId))
{
result.Add(button);
}
foreach (var child in node.Children)
CollectButtons(child, result);
}
private static bool IsEffectivelyVisible(UiElement element)
{
for (UiElement? e = element; e is not null; e = e.Parent)
{
if (!e.Visible) return false;
}
return true;
}
}

View file

@ -0,0 +1,192 @@
using System;
using System.Linq;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Player;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Tests for <see cref="CharacterSheetProvider"/> — the extracted character-sheet
/// assembly + raise flow (formerly private methods inside GameWindow). Covers the
/// XP-curve math against a known synthetic ExperienceTable and the single-owner
/// spend routing (table debits fire ObjectUpdated; LocalPlayerState debits fire
/// CharacterChanged).
/// </summary>
public sealed class CharacterSheetProviderTests
{
private const uint PlayerGuid = 0x50000001u;
/// <summary>Synthetic cumulative-XP curves. Levels band 1→2 spans 100..250.</summary>
private static DatReaderWriter.DBObjs.ExperienceTable MakeXpTable() => new()
{
Levels = new ulong[] { 0, 100, 250, 450 },
Attributes = new uint[] { 0, 10, 30, 60, 100 },
Vitals = new uint[] { 0, 4, 12, 24 },
TrainedSkills = new uint[] { 0, 5, 15, 30 },
SpecializedSkills = new uint[] { 0, 8, 24, 48 },
};
private sealed class Harness
{
public ClientObjectTable Table { get; } = new();
public LocalPlayerState Player { get; } = new();
public CharacterSheetProvider Provider { get; }
public (uint statId, ulong cost)? SentAttribute;
public (uint skillId, uint credits)? SentTrain;
public bool CanSend = true;
public Harness()
{
Provider = new CharacterSheetProvider(
Table, Player,
playerGuid: () => PlayerGuid,
activeToonName: () => "default",
fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 },
canSendRaise: () => CanSend,
sendRaiseAttribute: (statId, cost) => SentAttribute = (statId, cost),
sendRaiseVital: (_, _) => { },
sendRaiseSkill: (_, _) => { },
sendTrainSkill: (skillId, credits) => SentTrain = (skillId, credits))
{
ExperienceTable = MakeXpTable(),
};
}
/// <summary>Put the player's ClientObject in the table with live sheet properties.</summary>
public ClientObject AddPlayerObject(long unassignedXp = 1000L)
{
var player = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
player.Properties.Ints[0x19u] = 1; // level
player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band
player.Properties.Int64s[2u] = unassignedXp;
Table.AddOrUpdate(player);
return player;
}
}
[Fact]
public void BuildSheet_NoLiveData_UsesFallbackSheet()
{
var h = new Harness();
var sheet = h.Provider.BuildSheet();
Assert.Equal(-1, sheet.Level); // fallback marker
Assert.Equal("Player", sheet.Name); // toon key "default" + no object → "Player"
}
[Fact]
public void BuildSheet_LiveData_ComputesLevelBandAndRaiseCosts()
{
var h = new Harness();
h.AddPlayerObject(unassignedXp: 777L);
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); // Strength
var sheet = h.Provider.BuildSheet();
Assert.Equal("Testy", sheet.Name); // live object name beats "Player"
Assert.Equal(1, sheet.Level);
Assert.Equal(150L, sheet.TotalXp);
Assert.Equal(777L, sheet.UnassignedXp);
// Level band 100..250, at 150: 100 XP to next, 1/3 through the band.
Assert.Equal(100L, sheet.XpToNextLevel);
Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4);
Assert.Equal(11, sheet.Strength); // ranks + start
// Raise x1: Attributes[2] xpSpent = 30 10; x10 clamps at curve end: 100 10.
Assert.Equal(20L, sheet.AttributeRaiseCosts[0]);
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
}
[Fact]
public void BuildSheet_Skills_MapsAdvancementAndCurveCosts()
{
var h = new Harness();
h.AddPlayerObject();
h.Player.OnSkillUpdate(skillId: 6u, ranks: 1u, status: 2u, xp: 5u,
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // trained
h.Player.OnSkillUpdate(skillId: 7u, ranks: 0u, status: 0u, xp: 0u,
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // inactive → excluded
var sheet = h.Provider.BuildSheet();
var skill = Assert.Single(sheet.Skills);
Assert.Equal(6u, skill.Id);
Assert.Equal("Skill 6", skill.Name); // no SkillTable → id fallback name
Assert.Equal(CharacterSkillAdvancementClass.Trained, skill.AdvancementClass);
// TrainedSkills curve: x1 = 15 5; x10 clamps at index 3: 30 5.
Assert.Equal(10L, skill.RaiseCost);
Assert.Equal(25L, skill.Raise10Cost);
}
[Fact]
public void HandleRaiseRequest_Attribute_SendsAndDebitsThroughTableEvents()
{
var h = new Harness();
h.AddPlayerObject(unassignedXp: 1000L);
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
int tableUpdates = 0;
h.Table.ObjectUpdated += _ => tableUpdates++;
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
Assert.Equal((1u, 20ul), h.SentAttribute);
var strength = h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength);
Assert.Equal(2u, strength!.Value.Ranks); // optimistic rank apply
Assert.Equal(980L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // XP debited
Assert.True(tableUpdates >= 1); // via the eventful API
}
[Fact]
public void HandleRaiseRequest_Blocked_WhenCanSendIsFalse()
{
var h = new Harness();
h.AddPlayerObject(unassignedXp: 1000L);
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
h.CanSend = false;
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
Assert.Null(h.SentAttribute);
Assert.Equal(1u, h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength)!.Value.Ranks);
Assert.Equal(1000L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u));
}
[Fact]
public void HandleRaiseRequest_TrainSkill_DebitsFirstPresentCreditProperty()
{
var h = new Harness();
var player = h.AddPlayerObject();
player.Properties.Ints[0xC0u] = 4; // only the second id in the 0x18→0xC0→0xB5 chain
h.Player.OnSkillUpdate(skillId: 6u, ranks: 0u, status: 1u, xp: 0u,
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // untrained
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.TrainSkill, StatId: 6u, Cost: 4L, Amount: 1));
Assert.Equal((6u, 4u), h.SentTrain);
Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained
Assert.Equal(0, player.Properties.GetInt(0xC0u)); // credits debited
}
[Fact]
public void SpendUnassignedXp_FallsBackToLocalPlayer_WhenPlayerObjectAbsent()
{
var h = new Harness(); // note: nothing added to the table
var props = new PropertyBundle();
props.Int64s[2u] = 500L;
h.Player.OnProperties(props);
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
int changed = 0;
h.Player.CharacterChanged += () => changed++;
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 100L, Amount: 1));
Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side
Assert.True(changed >= 1); // and CharacterChanged fired
}
}

View file

@ -50,10 +50,114 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text);
Assert.Equal("Female Aluvian Adventurer", heritage.LinesProvider()[0].Text);
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
}
[Fact]
public void Bind_WindowChromeButton_InvokesCloseCallback()
{
var close = MakeButton(WindowChromeController.CharacterCloseButtonId);
var layout = Fake((WindowChromeController.CharacterCloseButtonId, close));
int closes = 0;
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++);
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
Assert.Equal(1, closes);
}
[Fact]
public void CharacterIdentityText_StatHeaderLine_ComposesRetailGenderHeritageTitle()
{
var sheet = new CharacterSheet
{
Gender = "Female",
Heritage = "Aluvian",
Title = "the Adventurer",
};
Assert.Equal("Female Aluvian Adventurer", CharacterIdentityText.StatHeaderLine(sheet));
}
[Theory]
[InlineData(1, "Male")]
[InlineData(2, "Female")]
[InlineData(0, null)]
public void CharacterIdentityText_GenderDisplayName_UsesRetailEnum(int value, string? expected)
=> Assert.Equal(expected, CharacterIdentityText.GenderDisplayName(value));
[Theory]
[InlineData(1, "Aluvian")]
[InlineData(2, "Gharu'ndim")]
[InlineData(5, "Umbraen")]
[InlineData(13, "Olthoi")]
[InlineData(99, null)]
public void CharacterIdentityText_HeritageGroupDisplayName_UsesRetailEnum(int value, string? expected)
=> Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName(value));
[Fact]
public void Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated()
{
var root = new UiPanel { Width = 300, Height = 600 };
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
var visibleName = new UiText { ElementId = CharacterStatController.NameId };
var hiddenName = new UiText { ElementId = CharacterStatController.NameId };
var visibleHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
var hiddenHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
var visibleLevel = new UiText { ElementId = CharacterStatController.LevelId };
var hiddenLevel = new UiText { ElementId = CharacterStatController.LevelId };
var visibleTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
var hiddenTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
var visibleTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
var hiddenTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
var visibleMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
var hiddenMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
var visibleXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
var hiddenXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
visibleMeter.AddChild(visibleXpNext);
hiddenMeter.AddChild(hiddenXpNext);
attrPage.AddChild(visibleName);
attrPage.AddChild(visibleHeritage);
attrPage.AddChild(visibleLevel);
attrPage.AddChild(visibleTotalXpLabel);
attrPage.AddChild(visibleTotalXp);
attrPage.AddChild(visibleMeter);
hiddenPage.AddChild(hiddenName);
hiddenPage.AddChild(hiddenHeritage);
hiddenPage.AddChild(hiddenLevel);
hiddenPage.AddChild(hiddenTotalXpLabel);
hiddenPage.AddChild(hiddenTotalXp);
hiddenPage.AddChild(hiddenMeter);
root.AddChild(attrPage);
root.AddChild(hiddenPage);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[CharacterStatController.NameId] = hiddenName,
[CharacterStatController.HeritageId] = hiddenHeritage,
[CharacterStatController.LevelId] = hiddenLevel,
[CharacterStatController.TotalXpLabelId] = hiddenTotalXpLabel,
[CharacterStatController.TotalXpId] = hiddenTotalXp,
[CharacterStatController.XpMeterId] = hiddenMeter,
[CharacterStatController.XpNextValueId] = hiddenXpNext,
});
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Studio Player", visibleName.LinesProvider()[0].Text);
Assert.Equal("Female Aluvian Adventurer", visibleHeritage.LinesProvider()[0].Text);
Assert.Equal("126", visibleLevel.LinesProvider()[0].Text);
Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text);
Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text);
Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text);
Assert.Empty(hiddenName.LinesProvider());
Assert.Empty(hiddenXpNext.LinesProvider());
}
// ── XP meter fill ────────────────────────────────────────────────────────
[Fact]
@ -514,6 +618,31 @@ public class CharacterStatControllerTests
Assert.Equal("Normal", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_OnlyOneAffordable_SplitsOneAndTenStates()
{
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
var sheet = new CharacterSheet
{
UnassignedXp = 500L,
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
};
CharacterStatController.Bind(layout, () => sheet);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Normal", btn1.ActiveState);
Assert.Equal("Ghosted", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_MaxedRow_ShowsGhostedState()
{
@ -535,6 +664,95 @@ public class CharacterStatControllerTests
Assert.Equal("Ghosted", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_ClickAffordableAttribute_EmitsRaiseRequest()
{
var btn1 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
btn1.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.Attribute, request.Kind);
Assert.Equal(5u, request.StatId);
Assert.Equal(110L, request.Cost);
Assert.Equal(1, request.Amount);
}
[Fact]
public void RaiseButtons_ClickAffordableAttribute_KeepsNormalStateUntilCostsRefresh()
{
var btn1 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Normal", btn1.ActiveState);
btn1.OnClick!();
Assert.Single(requests);
Assert.Equal("Normal", btn1.ActiveState);
}
[Fact]
public void RaiseButtons_ClickAffordableVital_EmitsMaxVitalId()
{
var btn1 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
list.Children.OfType<UiClickablePanel>().ToList()[6].OnClick!();
btn1.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.Vital, request.Kind);
Assert.Equal(1u, request.StatId);
Assert.Equal(90L, request.Cost);
Assert.Equal(1, request.Amount);
}
[Fact]
public void RaiseButtons_ClickUnaffordableTen_DoesNotEmitRequest()
{
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
var sheet = new CharacterSheet
{
UnassignedXp = 500L,
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
};
CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: requests.Add);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
btn10.OnClick!();
Assert.Empty(requests);
}
[Fact]
public void RaiseButtons_Deselect_HidesButtons()
{
@ -635,10 +853,7 @@ public class CharacterStatControllerTests
ClickTab(layout, left: 92f);
var headerPanels = list.Children
.Where(c => c is UiPanel and not UiClickablePanel)
.Cast<UiPanel>()
.ToList();
var headerPanels = SkillHeaders(list);
var headers = headerPanels
.Select(c => c.Children.OfType<UiText>().First().LinesProvider()[0].Text)
.ToList();
@ -654,7 +869,7 @@ public class CharacterStatControllerTests
Assert.All(headerPanels, h =>
Assert.Equal(Vector4.One, h.Children.OfType<UiText>().First().LinesProvider()[0].Color));
var rows = list.Children.OfType<UiClickablePanel>().ToList();
var rows = SkillRows(list);
Assert.Equal(12, rows.Count);
Assert.All(rows, row =>
{
@ -692,7 +907,7 @@ public class CharacterStatControllerTests
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
Assert.Equal(12, list.Children.OfType<UiClickablePanel>().Count());
Assert.Equal(12, SkillRows(list).Count);
ClickTab(layout, left: 0f);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
@ -704,8 +919,8 @@ public class CharacterStatControllerTests
public void SkillsTab_MouseClick_RebuildsVisibleListWhenListIdIsDuplicated()
{
var root = new UiPanel { Width = 300, Height = 600 };
var attrPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
var hiddenPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
var name = new UiText();
var visibleList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
var hiddenDuplicateList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
@ -761,7 +976,7 @@ public class CharacterStatControllerTests
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
var rows = SkillRows(list);
rows[1].OnClick!();
Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text);
@ -777,6 +992,31 @@ public class CharacterStatControllerTests
Assert.Equal(Vector4.Zero, rows[0].BackgroundColor);
}
[Fact]
public void SkillsTab_ClickRaiseTen_EmitsSkillRaiseRequest()
{
var list = new UiPanel { Width = 300 };
var btn10 = MakeButton();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.RaiseTenId, btn10));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16),
onRaiseRequest: requests.Add);
ClickTab(layout, left: 92f);
SkillRows(list)[1].OnClick!();
btn10.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.Skill, request.Kind);
Assert.Equal(34u, request.StatId);
Assert.Equal(111_000_000L, request.Cost);
Assert.Equal(10, request.Amount);
}
[Fact]
public void SkillsTab_SelectHealing_ShowsUntrainedSkillFooter()
{
@ -800,7 +1040,7 @@ public class CharacterStatControllerTests
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
list.Children.OfType<UiClickablePanel>().ToList()[5].OnClick!();
SkillRows(list)[5].OnClick!();
Assert.Equal("Healing", title.LinesProvider()[0].Text);
Assert.Equal("Skill Credits To Raise:", l1Label.LinesProvider()[0].Text);
@ -811,6 +1051,133 @@ public class CharacterStatControllerTests
Assert.Equal("Normal", btn1.ActiveState);
}
[Fact]
public void SkillsTab_ClickUntrainedSkill_EmitsTrainSkillRequest()
{
var list = new UiPanel { Width = 300 };
var btn1 = MakeButton();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.RaiseOneId, btn1));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16),
onRaiseRequest: requests.Add);
ClickTab(layout, left: 92f);
SkillRows(list)[5].OnClick!();
btn1.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
Assert.Equal(21u, request.StatId);
Assert.Equal(6L, request.Cost);
Assert.Equal(1, request.Amount);
}
[Fact]
public void SkillsTab_ClickTrain_RebuildsSelectedSkillAsTrained()
{
var list = new UiPanel { Width = 300 };
var btn1 = MakeButton();
var btn10 = MakeButton();
CharacterSheet sheet = new()
{
SkillCredits = 10,
UnassignedXp = 1_000,
Skills = new[]
{
new CharacterSkill(100u, "Train Me", 0x06000001u,
CharacterSkillAdvancementClass.Untrained,
BaseLevel: 5,
CurrentLevel: 5,
UsableUntrained: true,
TrainedCost: 4,
SpecializedCost: 0,
RaiseCost: 0,
Raise10Cost: 0),
},
};
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, () => sheet,
spriteResolve: id => (id, 16, 16),
onRaiseRequest: request =>
{
requests.Add(request);
sheet = new CharacterSheet
{
SkillCredits = 6,
UnassignedXp = 1_000,
Skills = new[]
{
new CharacterSkill(100u, "Train Me", 0x06000001u,
CharacterSkillAdvancementClass.Trained,
BaseLevel: 5,
CurrentLevel: 5,
UsableUntrained: true,
TrainedCost: 4,
SpecializedCost: 0,
RaiseCost: 10,
Raise10Cost: 100),
},
};
});
ClickTab(layout, left: 92f);
SkillRows(list).Single().OnClick!();
Assert.False(btn10.Visible);
btn1.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
Assert.Equal("Train Me", SkillRows(list).Single().Children.OfType<UiText>().ToList()[1].LinesProvider()[0].Text);
Assert.True(btn1.Visible);
Assert.True(btn10.Visible);
Assert.Equal("Normal", btn1.ActiveState);
Assert.Equal("Normal", btn10.ActiveState);
}
[Fact]
public void SkillsTab_BindsCharacterScrollbarToScrollableViewport()
{
var root = new UiPanel { Width = 300, Height = 600 };
var page = new UiPanel { Width = 300, Height = 600 };
var name = new UiText();
var list = MakeDatElement(CharacterStatController.ListBoxId, top: 137, width: 300, height: 80);
var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 137, width: 16, height: 80);
scrollbarShell.Left = 281;
page.AddChild(name);
page.AddChild(list);
page.AddChild(scrollbarShell);
root.AddChild(page);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[CharacterStatController.NameId] = name,
[CharacterStatController.ListBoxId] = list,
[CharacterStatController.ListScrollbarId] = scrollbarShell,
});
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
var bar = page.Children.OfType<UiScrollbar>().Single();
Assert.True(bar.Visible);
Assert.NotNull(bar.Model);
Assert.Contains(list.Children, c => c is UiScrollablePanel);
Assert.False(scrollbarShell.Visible);
}
[Fact]
public void GetRaiseCost_Index4Focus_Returns110()
{
@ -818,6 +1185,13 @@ public class CharacterStatControllerTests
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
}
[Fact]
public void GetRaiseCost_Amount10Index4Focus_Returns1100()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(1_100L, CharacterStatController.GetRaiseCost(sheet, 4, amount: 10));
}
[Fact]
public void GetRaiseCost_Index0Strength_Returns0()
{
@ -868,10 +1242,22 @@ public class CharacterStatControllerTests
Assert.Equal(9, costs.Length);
}
[Fact]
public void SampleCharacter_AttributeRaise10Costs_HasNineEntries()
{
var costs = SampleData.SampleCharacter().AttributeRaise10Costs;
Assert.NotNull(costs);
Assert.Equal(9, costs.Length);
}
[Fact]
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
[Fact]
public void SampleCharacter_AttributeRaise10Costs_FocusAt1100()
=> Assert.Equal(1_100L, SampleData.SampleCharacter().AttributeRaise10Costs[4]);
[Fact]
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
@ -1098,12 +1484,32 @@ public class CharacterStatControllerTests
private static string FirstRowName(UiElement list)
{
var row = list.Children.OfType<UiClickablePanel>().FirstOrDefault();
var row = Descendants(list).OfType<UiClickablePanel>().FirstOrDefault();
if (row is null) return "<no row>";
var texts = row.Children.OfType<UiText>().ToList();
return texts.Count > 1 ? texts[1].LinesProvider()[0].Text : "<no text>";
}
private static List<UiClickablePanel> SkillRows(UiElement list)
=> Descendants(list).OfType<UiClickablePanel>().ToList();
private static List<UiPanel> SkillHeaders(UiElement list)
=> Descendants(list)
.Where(c => c is UiPanel and not UiClickablePanel
&& c.Children.OfType<UiText>().Any())
.Cast<UiPanel>()
.ToList();
private static IEnumerable<UiElement> Descendants(UiElement root)
{
foreach (var child in root.Children)
{
yield return child;
foreach (var nested in Descendants(child))
yield return nested;
}
}
private static UiDatElement MakeDatElement(uint id, float top, float width, float height)
{
var info = new ElementInfo
@ -1123,9 +1529,9 @@ public class CharacterStatControllerTests
}
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
private static UiButton MakeButton()
private static UiButton MakeButton(uint id = 0u)
{
var info = new ElementInfo { Id = 0, Type = 1 };
var info = new ElementInfo { Id = id, Type = 1 };
return new UiButton(info, static _ => (0u, 0, 0));
}

View file

@ -136,6 +136,19 @@ public class ChatWindowControllerTests
// ── Test 3: Input is placed as a child of the input bar ─────────────────
[Fact]
public void Bind_Transcript_ForcesScrollableTextMode()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
Assert.False(ctrl!.Transcript.Centered);
Assert.False(ctrl.Transcript.RightAligned);
}
[Fact]
public void Bind_Input_IsChildOfInputBar()
{

View file

@ -63,6 +63,18 @@ public class DatWidgetFactoryTests
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
}
[Fact]
public void Create_PropagatesStateCursors()
{
var info = new ElementInfo { Type = 3 };
info.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06008888u, 7, 8);
var e = DatWidgetFactory.Create(info, NoTex, null)!;
Assert.Equal(new UiCursorMedia(0x06008888u, 7, 8),
e.CursorForState("Drag_rollover_accept", allowFallback: false));
}
// ── Test 5: ReadOrder propagated to ZOrder ───────────────────────────────
[Fact]

View file

@ -148,6 +148,22 @@ public class ElementReaderTests
Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]);
}
[Fact]
public void Merge_DerivedStateCursorOverridesBase()
{
var base_ = new ElementInfo();
base_.StateCursors[""] = new UiCursorMedia(0x06003000u, 1, 2);
base_.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06003001u, 3, 4);
var derived = new ElementInfo();
derived.StateCursors[""] = new UiCursorMedia(0x06004000u, 5, 6);
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(new UiCursorMedia(0x06004000u, 5, 6), merged.StateCursors[""]);
Assert.Equal(new UiCursorMedia(0x06003001u, 3, 4), merged.StateCursors["Drag_rollover_accept"]);
}
[Fact]
public void Merge_ChildrenComeFromDerived()
{

View file

@ -22,6 +22,7 @@ public class InventoryControllerTests
private const uint BurdenText = 0x100001D8u;
private const uint BurdenCaption = 0x100001D7u;
private const uint ContentsCaption = 0x100001C5u;
private const uint TitleText = 0x100001D3u;
private const uint ContentsScrollbar = 0x100001C7u;
private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
@ -35,16 +36,17 @@ public class InventoryControllerTests
var burdenText = new TestElement { Width = 36, Height = 15 };
var burdenCap = new TestElement { Width = 36, Height = 15 };
var contentsCap = new TestElement { Width = 192, Height = 15 };
var titleText = new TestElement { Width = 276, Height = 25 };
var scrollbar = new UiScrollbar { Width = 16, Height = 96 };
var root = new TestElement { Width = 300, Height = 362 };
root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
root.AddChild(contentsCap); root.AddChild(scrollbar);
root.AddChild(contentsCap); root.AddChild(titleText); root.AddChild(scrollbar);
var byId = new Dictionary<uint, UiElement>
{
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
[ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar,
[ContentsCaption] = contentsCap, [TitleText] = titleText, [ContentsScrollbar] = scrollbar,
};
return (new ImportedLayout(root, byId), grid, containers, top, meter,
burdenText, burdenCap, contentsCap);
@ -52,13 +54,23 @@ public class InventoryControllerTests
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
List<(uint item, uint container, int placement)>? puts = null)
List<(uint item, uint container, int placement)>? puts = null,
string? ownerName = null,
Action? onClose = null)
=> InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => strength, datFont: null,
ownerName: ownerName is null ? null : () => ownerName,
sendUse: uses is null ? null : g => uses.Add(g),
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)));
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
onClose: onClose);
private static UiButton MakeButton(uint id)
{
var info = new ElementInfo { Id = id, Type = 1 };
return new UiButton(info, static _ => (0u, 0, 0)) { Width = 16, Height = 16 };
}
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
@ -100,6 +112,23 @@ public class InventoryControllerTests
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
}
[Fact]
public void Populate_uses_manifest_container_hint_before_create_object_details()
{
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.ReplaceContents(Player, new[]
{
new ContainerContentEntry(0xC, 1u),
new ContainerContentEntry(0xA, 0u),
});
Bind(layout, objects);
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
}
[Fact]
public void Equipped_items_are_excluded_from_the_grid_and_selector()
{
@ -125,6 +154,7 @@ public class InventoryControllerTests
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
Bind(layout, new ClientObjectTable());
Assert.Equal(6, grid.Columns);
Assert.Equal(UiItemListFlow.RowMajor, grid.Flow);
Assert.Equal(32f, grid.CellWidth);
Assert.Equal(32f, grid.CellHeight);
}
@ -164,11 +194,27 @@ public class InventoryControllerTests
public void Captions_render_known_strings()
{
var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
Bind(layout, new ClientObjectTable());
var title = layout.FindElement(TitleText)!;
Bind(layout, new ClientObjectTable(), ownerName: "Horan");
Assert.Contains("Inventory of Horan", CaptionText(title));
Assert.Contains("Burden", CaptionText(burdenCap));
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
}
[Fact]
public void Window_chrome_button_invokes_close_callback()
{
var (layout, _, _, _, _, _, _, _) = BuildLayout();
var close = MakeButton(WindowChromeController.InventoryCloseButtonId);
layout.Root.AddChild(close);
int closes = 0;
Bind(layout, new ClientObjectTable(), onClose: () => closes++);
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
Assert.Equal(1, closes);
}
[Fact]
public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
{
@ -354,6 +400,42 @@ public class InventoryControllerTests
Assert.Empty(closes);
}
[Fact]
public void TargetMode_suppressesSelectedSquare_onPendingSource()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Type = ItemType.Creature,
ItemsCapacity = 102,
});
SeedContained(objects, 0xA, Player, slot: 0);
objects.Get(0xA)!.Useability = 0x000A0008u;
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => 100,
datFont: null,
itemInteraction: interaction);
grid.GetItem(0)!.Clicked!();
Assert.True(grid.GetItem(0)!.Selected);
Assert.True(interaction.ActivateItem(0xAu));
Assert.True(interaction.IsTargetModeActive);
Assert.False(grid.GetItem(0)!.Selected);
}
[Fact]
public void Default_mainPackCell_isOpenContainer()
{

View file

@ -2,6 +2,7 @@ using System;
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
@ -86,4 +87,34 @@ public class InventoryFrameImportProbe
"(else the Alphablend backdrop overpaints/washes out the panel content)");
}
}
[Fact]
public void Close_button_resolves_and_invokes_controller_close_callback()
{
var datDir = DatDir();
if (datDir is null) return; // CI: no live dat - skip
using var dats = new DatCollection(datDir, DatAccessType.Read);
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
Assert.NotNull(layout);
var close = layout!.FindElement(WindowChromeController.InventoryCloseButtonId);
Assert.NotNull(close);
Assert.True(close is UiButton or UiDatElement,
$"inventory close button resolved to {close?.GetType().Name ?? "null"}");
int closes = 0;
InventoryController.Bind(
layout,
new ClientObjectTable(),
playerGuid: static () => 0u,
iconIds: static (_, _, _, _, _) => 0u,
strength: static () => 100,
datFont: null,
onClose: () => closes++);
close!.OnEvent(new UiEvent(0u, close, UiEventType.Click));
Assert.Equal(1, closes);
}
}

View file

@ -40,6 +40,22 @@ public class LayoutImporterTests
/// draws nothing until a controller binds its <c>LinesProvider</c>);
/// the Type-3 must also be present.
/// </summary>
[Fact]
public void BuildFromInfos_StampsDatElementId_OnRootAndItemList()
{
const uint RootId = 0x100005F9u;
const uint ListId = 0x100001C6u;
var root = new ElementInfo { Id = RootId, Type = 3, Width = 160, Height = 58 };
var itemList = new ElementInfo { Id = ListId, Type = 0x10000031u, X = 5, Y = 5, Width = 72, Height = 72 };
var tree = LayoutImporter.BuildFromInfos(root, new[] { itemList }, NoTex, null);
Assert.Equal(RootId, tree.Root.DatElementId);
var found = Assert.IsType<UiItemList>(tree.FindElement(ListId));
Assert.Equal(ListId, found.DatElementId);
}
[Fact]
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
{

View file

@ -11,6 +11,8 @@ public class PaperdollControllerTests
private const uint Player = 0x50000001u;
private const uint Pack = 0x40000005u;
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
private const uint ChestSlot = 0x100001E2u; // ChestWear 0x2
private const uint ChestArmorSlot = 0x100005ACu; // ChestArmor 0x200
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
@ -19,7 +21,7 @@ public class PaperdollControllerTests
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
{
var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot };
var ids = new[] { HeadSlot, ChestSlot, ChestArmorSlot, ShieldSlot, WeaponSlot, FingerLSlot };
var lists = new Dictionary<uint, UiItemList>();
var byId = new Dictionary<uint, UiElement>();
var root = new RootElement { Width = 224, Height = 214 };
@ -112,6 +114,47 @@ public class PaperdollControllerTests
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
}
[Fact]
public void HandleDropRelease_onAutoWearSlot_sendsFullValidLocations()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
SeedPackItem(objects, 0xE02u, coatMask);
var wields = new List<(uint item, uint mask)>();
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xE02u, ItemDragSource.Inventory, 0, lists[ChestSlot].Cell);
ctrl.HandleDropRelease(lists[ChestSlot], lists[ChestSlot].Cell, payload);
Assert.Equal((uint)coatMask, wields[0].mask);
Assert.Equal(coatMask, objects.Get(0xE02u)!.CurrentlyEquippedLocation);
}
[Fact]
public void HandleDropRelease_onArmorAutoWearSlot_sendsFullValidLocations()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
const EquipMask hauberkMask =
EquipMask.ChestArmor
| EquipMask.AbdomenArmor
| EquipMask.UpperArmArmor
| EquipMask.LowerArmArmor;
SeedPackItem(objects, 0xE03u, hauberkMask);
var wields = new List<(uint item, uint mask)>();
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xE03u, ItemDragSource.Inventory, 0, lists[ChestArmorSlot].Cell);
ctrl.HandleDropRelease(lists[ChestArmorSlot], lists[ChestArmorSlot].Cell, payload);
Assert.Equal((uint)hauberkMask, wields[0].mask);
Assert.Equal(hauberkMask, objects.Get(0xE03u)!.CurrentlyEquippedLocation);
}
[Fact]
public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2)
{

View file

@ -18,6 +18,8 @@ public class ToolbarControllerTests
// The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list).
private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private const uint CharacterButtonId = 0x10000199u;
private const uint InventoryButtonId = 0x100001B1u;
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
Dictionary<uint, UiElement> indicators) FakeToolbar()
@ -34,6 +36,8 @@ public class ToolbarControllerTests
var e = new UiPanel { Visible = true };
dict[id] = e; indicators[id] = e; root.AddChild(e);
}
AddButton(CharacterButtonId);
AddButton(InventoryButtonId);
return (new ImportedLayout(root, dict), slots, indicators);
void AddSlot(uint id)
@ -41,6 +45,23 @@ public class ToolbarControllerTests
var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 };
dict[id] = list; slots[id] = list; root.AddChild(list);
}
void AddButton(uint id)
{
var info = new ElementInfo
{
Id = id,
Type = 1,
Width = 32,
Height = 32,
DefaultStateName = "Normal",
};
info.StateMedia["Normal"] = (0x1u, 1);
info.StateMedia["Highlight"] = (0x2u, 1);
var button = new UiButton(info, _ => (0u, 0, 0)) { Width = 32, Height = 32 };
dict[id] = button;
root.AddChild(button);
}
}
[Fact]
@ -95,6 +116,93 @@ public class ToolbarControllerTests
Assert.Equal(0x5001u, used);
}
[Fact]
public void WindowToggleButtons_clickCallbacks_fire()
{
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
int inventoryClicks = 0;
int characterClicks = 0;
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
ctrl.BindWindowToggles(
toggleInventory: () => inventoryClicks++,
toggleCharacter: () => characterClicks++);
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(1, inventoryClicks);
Assert.Equal(1, characterClicks);
}
[Fact]
public void InventoryButton_whenTargetModeActive_targetsPlayerInsteadOfTogglingInventory()
{
const uint player = 0x50000001u;
const uint pack = 0x50000010u;
const uint kit = 0x50000A01u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container, ItemsCapacity = 24 });
repo.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject { ObjectId = kit, Type = ItemType.Misc, Useability = 0x000A0008u });
repo.MoveItem(kit, pack, 0);
var useWithTarget = new List<(uint Source, uint Target)>();
var interaction = new ItemInteractionController(
repo,
playerGuid: () => player,
sendUse: null,
sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)),
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
int inventoryClicks = 0;
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u,
useItem: _ => { },
itemInteraction: interaction);
ctrl.BindWindowToggles(
toggleInventory: () => inventoryClicks++,
toggleCharacter: () => { });
interaction.ActivateItem(kit);
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(0, inventoryClicks);
Assert.Equal(new[] { (kit, player) }, useWithTarget);
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void WindowButtonState_highlightsWhenOpen()
{
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!;
var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!;
ctrl.SetInventoryOpen(true);
ctrl.SetCharacterOpen(true);
Assert.Equal("Highlight", inventoryButton.ActiveState);
Assert.Equal("Highlight", characterButton.ActiveState);
ctrl.SetInventoryOpen(false);
ctrl.SetCharacterOpen(false);
Assert.Equal("Normal", inventoryButton.ActiveState);
Assert.Equal("Normal", characterButton.ActiveState);
}
// ── C1: combat-mode indicator tests ─────────────────────────────────────
/// <summary>

View file

@ -0,0 +1,61 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using DatReaderWriter;
using DatReaderWriter.Options;
using SysEnv = System.Environment;
namespace AcDream.App.Tests.UI;
public sealed class RetailCursorCatalogTests
{
[Theory]
[InlineData(CursorFeedbackKind.TargetPending, 0x27u, 14, 14)]
[InlineData(CursorFeedbackKind.TargetValid, 0x28u, 14, 14)]
[InlineData(CursorFeedbackKind.TargetInvalid, 0x29u, 14, 14)]
public void TargetUseCursorSpecs_matchClientUISystemUpdateCursorState(
CursorFeedbackKind kind,
uint expectedEnum,
int expectedHotspotX,
int expectedHotspotY)
{
Assert.Equal(6u, RetailCursorCatalog.CursorEnumTable);
Assert.True(RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec));
Assert.Equal(expectedEnum, spec.EnumId);
Assert.Equal(expectedHotspotX, spec.HotspotX);
Assert.Equal(expectedHotspotY, spec.HotspotY);
}
[Fact]
public void TargetUseCursorSpecs_resolveGoldenDatSurfaces()
{
string? datDir = ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var resolver = new RetailCursorResolver(dats, new object());
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x27u, 14, 14), out var pending));
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x28u, 14, 14), out var valid));
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x29u, 14, 14), out var invalid));
Assert.Equal(new UiCursorMedia(0x06004D73u, 14, 14), pending);
Assert.Equal(new UiCursorMedia(0x06005E6Bu, 14, 14), valid);
Assert.Equal(new UiCursorMedia(0x06005E6Au, 14, 14), invalid);
}
private static string? ResolveDatDir()
{
string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string defaultDir = Path.Combine(
SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
return Directory.Exists(defaultDir) ? defaultDir : null;
}
}

View file

@ -0,0 +1,179 @@
using System.Collections.Generic;
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Testing;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class RetailUiAutomationProbeTests
{
private sealed class SpyHandler : IItemListDragHandler
{
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
=> LastLift = (sourceList, sourceCell, payload);
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> true;
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> LastDrop = (targetList, targetCell, payload);
}
private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects)
RootWithTwoItemLists()
{
var root = new UiRoot { Width = 240, Height = 120 };
var objects = new ClientObjectTable();
var source = new UiItemList(_ => (1u, 1, 1))
{
DatElementId = 0x10000010u,
Left = 10,
Top = 10,
Width = 32,
Height = 32,
};
source.Cell.SlotIndex = 0;
source.Cell.SourceKind = ItemDragSource.Inventory;
source.Cell.SetItem(0x5001u, 0x99u);
var target = new UiItemList(_ => (1u, 1, 1))
{
DatElementId = 0x10000020u,
Left = 70,
Top = 10,
Width = 32,
Height = 32,
};
target.Cell.SlotIndex = 1;
var handler = new SpyHandler();
target.RegisterDragHandler(handler);
root.AddChild(source);
root.AddChild(target);
return (root, source, target, handler, objects);
}
[Fact]
public void Snapshot_listsDatElementsAndItemSlots()
{
var (root, _, _, _, objects) = RootWithTwoItemLists();
var probe = new RetailUiAutomationProbe(root, objects);
var rows = probe.Snapshot();
Assert.Contains(rows, r => r.DatElementId == 0x10000010u && r.TypeName == nameof(UiItemList));
var itemRow = Assert.Single(rows, r => r.ItemId == 0x5001u);
Assert.Equal(ItemDragSource.Inventory, itemRow.SourceKind);
Assert.Equal(0, itemRow.SlotIndex);
Assert.True(itemRow.Width > 0f);
Assert.True(itemRow.Height > 0f);
}
[Fact]
public void DoubleClickItem_routesThroughUiRootDoubleClick()
{
var (root, source, _, _, objects) = RootWithTwoItemLists();
bool doubleClicked = false;
source.Cell.DoubleClicked = () => doubleClicked = true;
var probe = new RetailUiAutomationProbe(root, objects);
Assert.True(probe.DoubleClickItem(0x5001u, ItemDragSource.Inventory));
Assert.True(doubleClicked);
}
[Fact]
public void DragItemToElement_deliversDropReleaseToTargetCell()
{
var (root, _, target, handler, objects) = RootWithTwoItemLists();
var probe = new RetailUiAutomationProbe(root, objects);
Assert.True(probe.DragItemToElement(0x5001u, 0x10000020u, ItemDragSource.Inventory));
Assert.NotNull(handler.LastDrop);
Assert.Same(target, handler.LastDrop!.Value.list);
Assert.Same(target.Cell, handler.LastDrop.Value.cell);
Assert.Equal(0x5001u, handler.LastDrop.Value.payload.ObjId);
}
[Fact]
public void DragItemOutside_raisesRootOutsideUiEvent()
{
var (root, _, _, _, objects) = RootWithTwoItemLists();
object? payload = null;
(int x, int y) release = default;
root.DragReleasedOutsideUi += (p, x, y) =>
{
payload = p;
release = (x, y);
};
var probe = new RetailUiAutomationProbe(root, objects);
Assert.True(probe.DragItemOutside(0x5001u, 220, 100, ItemDragSource.Inventory));
var itemPayload = Assert.IsType<ItemDragPayload>(payload);
Assert.Equal(0x5001u, itemPayload.ObjId);
Assert.Equal((220, 100), release);
}
[Fact]
public void AssertItem_checksObjectTableState()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x5001u,
ContainerId = 0x7001u,
ContainerSlot = 3,
CurrentlyEquippedLocation = EquipMask.ChestArmor | EquipMask.UpperArmArmor,
});
var probe = new RetailUiAutomationProbe(new UiRoot(), objects);
var ok = probe.AssertItem(
0x5001u,
equippedLocation: EquipMask.ChestArmor | EquipMask.UpperArmArmor,
containerId: 0x7001u,
slot: 3);
var bad = probe.AssertItem(0x5001u, slot: 4);
Assert.True(ok.Success);
Assert.False(bad.Success);
}
[Fact]
public void ScriptRunner_waitItemThenDoubleClick_executesThroughProbe()
{
var (root, source, _, _, objects) = RootWithTwoItemLists();
bool doubleClicked = false;
source.Cell.DoubleClicked = () => doubleClicked = true;
var probe = new RetailUiAutomationProbe(root, objects);
var logs = new List<string>();
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
File.WriteAllLines(path, new[]
{
"wait item 0x5001 inventory 100",
"doubleclick item 0x5001 inventory",
});
try
{
var runner = new RetailUiAutomationScriptRunner(probe, path, dumpOnStart: false, logs.Add);
runner.Tick(0.016);
Assert.True(doubleClicked);
Assert.True(runner.Completed);
Assert.Contains(logs, line => line.Contains("UI probe script complete"));
}
finally
{
File.Delete(path);
}
}
}

View file

@ -0,0 +1,291 @@
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class RetailUiInteractionFlowTests
{
private const uint Player = 0x50000001u;
private const uint Hauberk = 0x50001001u;
private const uint HealthKit = 0x50001002u;
private const uint SlotsButtonId = 0x100005BEu;
private const uint ChestArmorSlotId = 0x100005ACu;
private const uint HealthKitUseability = 0x000A0008u;
private const EquipMask HauberkMask =
EquipMask.ChestArmor
| EquipMask.AbdomenArmor
| EquipMask.UpperArmArmor
| EquipMask.LowerArmArmor;
private sealed class TestElement : UiElement { }
private sealed class Harness
{
public readonly UiRoot Root = new() { Width = 800, Height = 600 };
public readonly ClientObjectTable Objects = new();
public readonly ImportedLayout Layout;
public readonly List<uint> Uses = new();
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<uint> Drops = new();
public long Now = 10_000;
public Harness()
{
var layoutRoot = new TestElement { Width = 360, Height = 180 };
var grid = ItemList(
InventoryController.ContentsGridId,
left: 10,
top: 10,
width: 192,
height: 96);
var sideBags = ItemList(
InventoryController.ContainerListId,
left: 210,
top: 10,
width: 36,
height: 96);
var mainPack = ItemList(
InventoryController.TopContainerId,
left: 210,
top: 112,
width: 36,
height: 36);
var chestArmor = ItemList(
ChestArmorSlotId,
left: 280,
top: 10,
width: 32,
height: 32);
var slotsButton = new UiButton(
new ElementInfo { Id = SlotsButtonId, Type = 1 },
static _ => (0u, 0, 0))
{
DatElementId = SlotsButtonId,
Left = 270,
Top = 56,
Width = 56,
Height = 20,
};
var meter = new UiMeter
{
DatElementId = InventoryController.BurdenMeterId,
Left = 248,
Top = 10,
Width = 11,
Height = 58,
};
var titleText = TextHost(InventoryController.TitleTextId, 10, 116, 192, 14);
var burdenText = TextHost(InventoryController.BurdenTextId, 248, 72, 36, 14);
var burdenCaption = TextHost(InventoryController.BurdenCaptionId, 248, 88, 36, 14);
var contentsCaption = TextHost(InventoryController.ContentsCaptionId, 10, 132, 192, 14);
var scrollbar = new UiScrollbar
{
DatElementId = InventoryController.ContentsScrollbarId,
Left = 192,
Top = 10,
Width = 16,
Height = 96,
};
var byId = new Dictionary<uint, UiElement>
{
[InventoryController.ContentsGridId] = grid,
[InventoryController.ContainerListId] = sideBags,
[InventoryController.TopContainerId] = mainPack,
[InventoryController.BurdenMeterId] = meter,
[InventoryController.TitleTextId] = titleText,
[InventoryController.BurdenTextId] = burdenText,
[InventoryController.BurdenCaptionId] = burdenCaption,
[InventoryController.ContentsCaptionId] = contentsCaption,
[InventoryController.ContentsScrollbarId] = scrollbar,
[ChestArmorSlotId] = chestArmor,
[SlotsButtonId] = slotsButton,
};
layoutRoot.AddChild(grid);
layoutRoot.AddChild(sideBags);
layoutRoot.AddChild(mainPack);
layoutRoot.AddChild(chestArmor);
layoutRoot.AddChild(slotsButton);
layoutRoot.AddChild(meter);
layoutRoot.AddChild(titleText);
layoutRoot.AddChild(burdenText);
layoutRoot.AddChild(burdenCaption);
layoutRoot.AddChild(contentsCaption);
layoutRoot.AddChild(scrollbar);
Layout = new ImportedLayout(layoutRoot, byId);
Root.AddChild(layoutRoot);
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
}
public ItemInteractionController BindInventoryInteraction()
{
var interaction = new ItemInteractionController(
Objects,
playerGuid: () => Player,
sendUse: Uses.Add,
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
sendWield: (item, mask) => Wields.Add((item, mask)),
sendDrop: Drops.Add,
nowMs: () => Now);
InventoryController.Bind(
Layout,
Objects,
playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u,
strength: () => 100,
datFont: null,
itemInteraction: interaction);
Root.DragReleasedOutsideUi += (payload, _, _) =>
{
if (payload is ItemDragPayload itemPayload)
interaction.DropToWorld(itemPayload);
};
return interaction;
}
public void BindPaperdoll()
=> PaperdollController.Bind(
Layout,
Objects,
playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u,
sendWield: (item, mask) => Wields.Add((item, mask)),
emptySlotSprite: 0x06004D20u);
public RetailUiAutomationProbe Probe()
=> new(Root, Objects);
public void SeedHauberk(int slot = 0)
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Hauberk,
Name = "Hauberk",
Type = ItemType.Armor,
ValidLocations = HauberkMask,
IconId = 0x06001234u,
});
Objects.MoveItem(Hauberk, Player, slot);
}
public void SeedHealthKit(int slot = 0)
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = HealthKit,
Name = "Health Kit",
Type = ItemType.Misc,
IconId = 0x06001235u,
Useability = HealthKitUseability,
});
Objects.MoveItem(HealthKit, Player, slot);
}
private static UiItemList ItemList(uint id, float left, float top, float width, float height)
=> new(static _ => (1u, 32, 32))
{
DatElementId = id,
Left = left,
Top = top,
Width = width,
Height = height,
};
private static UiElement TextHost(uint id, float left, float top, float width, float height)
=> new TestElement
{
DatElementId = id,
Left = left,
Top = top,
Width = width,
Height = height,
ClickThrough = true,
};
}
[Fact]
public void InventoryDoubleClick_hauberk_routesThroughUiRootAndEquipsFullMask()
{
var h = new Harness();
h.SeedHauberk();
h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
Assert.True(state.Success, state.Message);
}
[Fact]
public void InventoryTargetUse_thenMainPackClick_selfTargetsThroughUiRoot()
{
var h = new Harness();
h.SeedHealthKit();
var interaction = h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory));
Assert.True(interaction.IsTargetModeActive);
Assert.True(probe.ClickElement(InventoryController.TopContainerId));
Assert.Equal(new[] { (HealthKit, Player) }, h.UseWithTarget);
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void InventoryDragOutsideUi_routesThroughRootOutsideHookAndDropsToWorld()
{
var h = new Harness();
h.SeedHauberk();
h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory));
Assert.Equal(new[] { Hauberk }, h.Drops);
var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1);
Assert.True(state.Success, state.Message);
}
[Fact]
public void InventoryDragToPaperdollChestArmorSlot_usesFullHauberkCoverageMask()
{
var h = new Harness();
h.SeedHauberk();
h.BindInventoryInteraction();
h.BindPaperdoll();
var probe = h.Probe();
Assert.True(probe.ClickElement(SlotsButtonId)); // show armor slots; retail defaults to doll-view.
Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
Assert.True(state.Success, state.Message);
}
}

View file

@ -12,6 +12,14 @@ public class UiItemListGridTests
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
}
[Fact]
public void CellOffset_ColumnMajor_UsesLogicalRows()
{
Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
Assert.Equal((0f, 36f), UiItemList.CellOffset(1, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
Assert.Equal((36f, 0f), UiItemList.CellOffset(3, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
}
[Fact]
public void GridMode_PositionsCellsInColumns()
{
@ -26,6 +34,25 @@ public class UiItemListGridTests
Assert.Equal(36f, c4.Height);
}
[Fact]
public void GridMode_ColumnMajor_PositionsCellsDownRowsFirst()
{
var list = new UiItemList
{
Columns = 3,
Flow = UiItemListFlow.ColumnMajor,
CellWidth = 36,
CellHeight = 36,
};
list.Flush();
for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());
var c3 = list.GetItem(3)!;
Assert.Equal(36f, c3.Left);
Assert.Equal(0f, c3.Top);
}
[Fact]
public void FillMode_SizesSingleCellToList()
{

View file

@ -39,6 +39,18 @@ public class UiItemSlotTests
Assert.Equal(0u, s.IconTexture);
}
[Fact]
public void DoubleClick_invokesDoubleClicked()
{
var s = new UiItemSlot();
bool fired = false;
s.DoubleClicked = () => fired = true;
s.OnEvent(new UiEvent(0u, s, UiEventType.DoubleClick));
Assert.True(fired);
}
// ── Shortcut number tests ────────────────────────────────────────────────
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).

View file

@ -18,11 +18,14 @@ public class UiRootInputTests
{
private readonly bool _handlesClick;
public bool Clicked;
public int Clicks;
public int DoubleClicks;
public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick;
public override bool HandlesClick => _handlesClick;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click) { Clicked = true; return true; }
if (e.Type == UiEventType.Click) { Clicked = true; Clicks++; return true; }
if (e.Type == UiEventType.DoubleClick) { DoubleClicks++; return true; }
return false;
}
}
@ -45,6 +48,51 @@ public class UiRootInputTests
Assert.True(btn.Clicked);
}
[Fact]
public void DoubleClick_EmitsRealDoubleClickEvent()
{
var root = new UiRoot { Width = 800, Height = 600 };
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
root.AddChild(btn);
root.Tick(0, nowMs: 1_000);
root.OnMouseDown(UiMouseButton.Left, 20, 10);
root.OnMouseUp(UiMouseButton.Left, 20, 10);
root.Tick(0, nowMs: 1_300);
root.OnMouseDown(UiMouseButton.Left, 20, 10);
root.OnMouseUp(UiMouseButton.Left, 20, 10);
Assert.Equal(2, btn.Clicks);
Assert.Equal(1, btn.DoubleClicks);
}
[Fact]
public void ItemDragReleasedOutsideUi_raisesOutsideReleaseEvent()
{
var root = new UiRoot { Width = 800, Height = 600 };
var cell = new UiItemSlot { Left = 0, Top = 0, Width = 32, Height = 32 };
cell.SetItem(0x50000A01u, 0x99u);
root.AddChild(cell);
object? payload = null;
int releaseX = 0;
int releaseY = 0;
root.DragReleasedOutsideUi += (p, x, y) =>
{
payload = p;
releaseX = x;
releaseY = y;
};
root.OnMouseDown(UiMouseButton.Left, 5, 5);
root.OnMouseMove(20, 20);
root.OnMouseUp(UiMouseButton.Left, 200, 200);
var drag = Assert.IsType<ItemDragPayload>(payload);
Assert.Equal(0x50000A01u, drag.ObjId);
Assert.Equal(200, releaseX);
Assert.Equal(200, releaseY);
}
[Fact]
public void PlainWidget_insideDraggableWindow_doesNotEmitClick()
{
@ -327,14 +375,18 @@ public class UiRootInputTests
root.AddChild(win);
root.RegisterWindow("inventory", win);
Assert.False(root.IsWindowVisible("inventory"));
Assert.True(root.ShowWindow("inventory"));
Assert.True(win.Visible);
Assert.True(root.IsWindowVisible("inventory"));
Assert.True(root.HideWindow("inventory"));
Assert.False(win.Visible);
Assert.False(root.IsWindowVisible("inventory"));
Assert.False(root.ShowWindow("nope"));
Assert.False(root.HideWindow("nope"));
Assert.False(root.ToggleWindow("nope"));
Assert.False(root.IsWindowVisible("nope"));
}
[Fact]

View file

@ -0,0 +1,39 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiScrollablePanelTests
{
[Fact]
public void LayoutScrollableChildren_ClipsRowsOutsideViewport()
{
var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 };
for (int i = 0; i < 4; i++)
panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 });
panel.LayoutScrollableChildren();
Assert.True(panel.Children[0].Visible);
Assert.True(panel.Children[1].Visible);
Assert.False(panel.Children[2].Visible);
panel.Scroll.SetScrollY(20);
panel.LayoutScrollableChildren();
Assert.False(panel.Children[0].Visible);
Assert.True(panel.Children[1].Visible);
Assert.True(panel.Children[2].Visible);
}
[Fact]
public void ScrollEvent_MovesByLineHeight()
{
var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 };
for (int i = 0; i < 4; i++)
panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 });
panel.LayoutScrollableChildren();
var e = new UiEvent(0u, panel, UiEventType.Scroll, Data0: -1);
Assert.True(panel.OnEvent(in e));
Assert.Equal(20, panel.Scroll.ScrollY);
}
}

View file

@ -37,14 +37,14 @@ public sealed class GameEventWiringTests
return body;
}
private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll()
private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll(Func<uint>? playerGuid = null)
{
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, playerGuid: playerGuid);
return (dispatcher, items, combat, spellbook, chat);
}
@ -123,20 +123,20 @@ public sealed class GameEventWiringTests
[Fact]
public void WireAll_WieldObject_ConfirmsOptimisticWield()
{
var (d, items, _, _, _) = MakeAll();
const uint player = 0x2000u;
var (d, items, _, _, _) = MakeAll(() => player);
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
byte[] payload = new byte[12];
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
d.Dispatch(env!.Value); // server confirms the wield
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
Assert.Equal(player, items.Get(0x1500)!.ContainerId);
}
[Fact]
@ -215,6 +215,59 @@ public sealed class GameEventWiringTests
Assert.Equal(0.5f, local.ManaPercent!.Value, precision: 3);
}
[Fact]
public void WireAll_PlayerDescription_PopulatesLocalPlayerStateSkills()
{
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
var local = new LocalPlayerState();
int callbackRun = -1;
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local,
onSkillsUpdated: (run, _) => callbackRun = run,
resolveSkillFormulaBonus: (skillId, attrs) =>
{
Assert.Equal(24u, skillId);
Assert.Equal(50u, attrs[1u]);
return 80u;
});
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0u); // propertyFlags
w.Write(0x52u); // weenieType
w.Write(0x03u); // vectorFlags = Attribute | Skill
w.Write(0u); // has_health = false
w.Write(0x01u); // attribute_flags = Strength only
w.Write(0u); // Strength ranks
w.Write(50u); // Strength start
w.Write(0u); // Strength xp
w.Write((ushort)1); // skill count
w.Write((ushort)8); // buckets
w.Write(24u); // Run
w.Write((ushort)12);
w.Write((ushort)1); // const_one
w.Write(2u); // trained
w.Write(3456u); // xp
w.Write(30u); // init
w.Write(0u); // resistance
w.Write(1.5); // last used
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
var run = local.GetSkill(24u);
Assert.NotNull(run);
Assert.Equal(122u, run!.Value.CurrentLevel);
Assert.Equal(2u, run.Value.Status);
Assert.Equal(3456u, run.Value.Xp);
Assert.Equal(122, callbackRun);
}
[Fact]
public void WireAll_PlayerDescription_FeedsSpellbook()
{
@ -392,6 +445,48 @@ public sealed class GameEventWiringTests
Assert.Equal(2, items.ObjectCount);
Assert.NotNull(items.Get(0x50000A01u));
Assert.NotNull(items.Get(0x50000A02u));
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
}
[Fact]
public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder()
{
const uint playerGuid = 0x50000001u;
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat,
playerGuid: () => playerGuid);
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0u); // propertyFlags = 0
w.Write(0x52u); // weenieType
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
w.Write(1u); // has_health
w.Write(0u); // attribute_flags = 0 (no attrs)
w.Write(0u); // enchantment_mask = 0
w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS -> strict inv path)
w.Write(0u); // options1
w.Write(0u); // legacy hotbar list count = 0
w.Write(0u); // spellbook_filters
w.Write(2u);
w.Write(0x50000A02u); w.Write(1u);
w.Write(0x50000A01u); w.Write(0u);
w.Write(0u); // equipped count
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
Assert.Equal(new[] { 0x50000A02u, 0x50000A01u }, items.GetContents(playerGuid));
Assert.Equal(playerGuid, items.Get(0x50000A02u)!.ContainerId);
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
}
[Fact]
@ -441,6 +536,7 @@ public sealed class GameEventWiringTests
Assert.NotNull(items.Get(0x700u));
// (b) WeenieClassId must be 0, NOT the ContainerType discriminator (1) — misuse gone
Assert.Equal(0u, items.Get(0x700u)!.WeenieClassId);
Assert.Equal(1u, items.Get(0x700u)!.ContainerTypeHint);
// (c) equipped guid has its equip slot set
Assert.NotNull(items.Get(0x701u));
Assert.Equal(EquipMask.MeleeWeapon, items.Get(0x701u)!.CurrentlyEquippedLocation);
@ -573,6 +669,9 @@ public sealed class GameEventWiringTests
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u));
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
}
[Fact]
@ -638,13 +737,14 @@ public sealed class GameEventWiringTests
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); // containerType
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload));
d.Dispatch(env!.Value);
// ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck).
Assert.False(items.RollbackMove(0x50000B02u));
Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId);
Assert.Equal(1u, items.Get(0x50000B02u)!.ContainerTypeHint);
}
[Fact]
@ -661,4 +761,21 @@ public sealed class GameEventWiringTests
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
}
[Fact]
public void WireAll_InventoryPutObjectIn3D_ConfirmsOptimisticDrop()
{
var (d, items, _, _, _) = MakeAll();
items.RecordMembership(0x50000A03u, containerId: 0x500000C9u);
items.MoveItem(0x50000A03u, 0x500000C9u, 4);
items.MoveItemOptimistic(0x50000A03u, newContainerId: 0u, newSlot: -1);
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A03u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload));
d.Dispatch(env!.Value);
Assert.Equal(0u, items.Get(0x50000A03u)!.ContainerId);
Assert.False(items.RollbackMove(0x50000A03u));
}
}

View file

@ -156,6 +156,22 @@ public sealed class CreateObjectTests
Assert.Equal(2.5f, parsed.Value.UseRadius!.Value, precision: 3);
}
[Fact]
public void TryParse_WeenieFlagsTargetType_ReadsTargetType()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000009u,
name: "Healing Kit",
itemType: (uint)ItemType.Misc,
weenieFlags: 0x00080000u,
targetType: (uint)ItemType.Creature);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
}
// -----------------------------------------------------------------------
// D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the
// action bar / equipment UI can read icon dat ids from spawn messages.
@ -451,6 +467,7 @@ public sealed class CreateObjectTests
uint? value = null,
uint? useability = null,
float? useRadius = null,
uint? targetType = null,
uint iconOverlayId = 0,
uint iconUnderlayId = 0,
// intermediate fields for cursor-arithmetic test
@ -516,7 +533,7 @@ public sealed class CreateObjectTests
BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f);
bytes.AddRange(tmp.ToArray());
}
if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, 0); // TargetType u32
if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, targetType ?? 0u); // TargetType u32
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte
if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16

View file

@ -1,6 +1,7 @@
using System;
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using Xunit;
@ -176,6 +177,21 @@ public sealed class GameEventDispatcherTests
Assert.Equal(0.42f, parsed.Value.HealthPercent, 4);
}
[Fact]
public void ParseWieldObject_acceptsAceEightBytePayload()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.ChestArmor);
var parsed = GameEvents.ParseWieldObject(payload);
Assert.NotNull(parsed);
Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid);
Assert.Equal((uint)EquipMask.ChestArmor, parsed.Value.EquipLoc);
Assert.Equal(0u, parsed.Value.WielderGuid);
}
[Fact]
public void ParseWeenieError_RoundTrip()
{

View file

@ -57,6 +57,8 @@ public sealed class ObjectTableWiringTests
Structure = 80,
MaxStructure = 100,
Workmanship = 4.5f,
Useability = 0x000A0008u,
TargetType = (uint)ItemType.Creature,
};
var d = ObjectTableWiring.ToWeenieData(spawn);
@ -96,6 +98,8 @@ public sealed class ObjectTableWiringTests
Assert.Equal(80, d.Structure);
Assert.Equal(100, d.MaxStructure);
Assert.Equal(4.5f, d.Workmanship);
Assert.Equal(0x000A0008u, d.Useability);
Assert.Equal((uint)ItemType.Creature, d.TargetType);
}
// -------------------------------------------------------------------------

View file

@ -28,6 +28,58 @@ public sealed class WorldSessionCombatTests
CharacterActions.CombatMode.Magic), captured);
}
[Fact]
public void SendRaiseAttribute_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendRaiseAttribute(5u, 110u);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildRaiseAttribute(1, 5u, 110u), captured);
}
[Fact]
public void SendRaiseVital_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendRaiseVital(1u, 90u);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildRaiseVital(1, 1u, 90u), captured);
}
[Fact]
public void SendRaiseSkill_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendRaiseSkill(34u, 111_000_000u);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildRaiseSkill(1, 34u, 111_000_000u), captured);
}
[Fact]
public void SendTrainSkill_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendTrainSkill(21u, 6u);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildTrainSkill(1, 21u, 6u), captured);
}
[Fact]
public void SendMeleeAttack_UsesRetailMeleeBuilder()
{
@ -88,4 +140,19 @@ public sealed class WorldSessionCombatTests
Assert.NotNull(captured);
Assert.Equal(SocialActions.BuildQueryHealth(1, 0x50000007u), captured);
}
[Fact]
public void SendUseWithTarget_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendUseWithTarget(0x50000A01u, 0x50000001u);
Assert.NotNull(captured);
Assert.Equal(
InteractRequests.BuildUseWithTarget(1, 0x50000A01u, 0x50000001u),
captured);
}
}

View file

@ -152,18 +152,38 @@ public sealed class ClientObjectTableTests
Assert.Equal(0u, repo.Get(0x500000ACu)!.Effects);
}
[Fact]
public void UpdateIntProperty_currentWieldedLocation_updatesTypedEquipLocation()
{
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject
{
ObjectId = 0x500000ADu,
CurrentlyEquippedLocation = EquipMask.ChestArmor,
});
Assert.True(repo.UpdateIntProperty(
0x500000ADu,
ClientObjectTable.CurrentWieldedLocationPropertyId,
value: 0));
Assert.Equal(EquipMask.None, repo.Get(0x500000ADu)!.CurrentlyEquippedLocation);
Assert.Equal(0, repo.Get(0x500000ADu)!.Properties.Ints[ClientObjectTable.CurrentWieldedLocationPropertyId]);
}
[Fact]
public void ClientObject_NewFields_DefaultAndSettable()
{
var o = new ClientObject
{
ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7,
Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f,
ContainerTypeHint = 1u, Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f,
};
o.WeenieClassId = 0xABCDu; // now settable
Assert.Equal(0x42u, o.WielderId);
Assert.Equal(24, o.ItemsCapacity);
Assert.Equal(7, o.ContainersCapacity);
Assert.Equal(1u, o.ContainerTypeHint);
Assert.Equal(8u, o.Priority);
Assert.Equal(5, o.Structure);
Assert.Equal(10, o.MaxStructure);
@ -239,6 +259,24 @@ public sealed class ClientObjectTableTests
Assert.Equal(0u, table.Get(0x500000B3u)!.Effects);
}
[Fact]
public void Ingest_UseabilityAndTargetType_AssignsTypedFields()
{
var table = new ClientObjectTable();
var data = FullWeenie(0x500000B7u) with
{
Useability = 0x000A0008u,
TargetType = (uint)ItemType.Creature,
};
table.Ingest(data);
var item = table.Get(0x500000B7u);
Assert.NotNull(item);
Assert.Equal(0x000A0008u, item!.Useability);
Assert.Equal((uint)ItemType.Creature, item.TargetType);
}
[Fact]
public void RecordMembership_CreatesEntry_AndSetsEquip()
{
@ -271,6 +309,21 @@ public sealed class ClientObjectTableTests
Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x500000B6u)!.CurrentlyEquippedLocation);
}
[Fact]
public void RecordMembership_inventoryEntryClearsStaleEquipLocation()
{
var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject
{
ObjectId = 0x500000B8u,
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
});
table.RecordMembership(0x500000B8u);
Assert.Equal(EquipMask.None, table.Get(0x500000B8u)!.CurrentlyEquippedLocation);
}
[Fact]
public void ContainerIndex_IngestThenContents_OrderedBySlot()
{
@ -340,6 +393,40 @@ public sealed class ClientObjectTableTests
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
}
[Fact]
public void ReplaceContents_listedMemberClearsStaleEquipLocation()
{
var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject
{
ObjectId = 0xA10u,
ContainerId = 0x50000001u,
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
});
table.ReplaceContents(0x50000001u, new uint[] { 0xA10u });
var item = table.Get(0xA10u)!;
Assert.Equal(0x50000001u, item.ContainerId);
Assert.Equal(0, item.ContainerSlot);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
}
[Fact]
public void ReplaceContents_detachedMemberClearsStaleEquipLocation()
{
var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject { ObjectId = 0xA11u });
table.MoveItem(0xA11u, 0x50000001u, newSlot: 0,
newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear);
table.ReplaceContents(0x50000001u, Array.Empty<uint>());
var item = table.Get(0xA11u)!;
Assert.Equal(0u, item.ContainerId);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
}
[Fact]
public void ReplaceContents_ReordersToNewListOrder()
{
@ -349,6 +436,54 @@ public sealed class ClientObjectTableTests
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
}
[Fact]
public void ReplaceContents_RecordsContainerTypeHintAndOrder()
{
var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new[]
{
new ContainerContentEntry(0xA02u, 1u),
new ContainerContentEntry(0xA01u, 0u),
});
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
Assert.Equal(0u, table.Get(0xA01u)!.ContainerTypeHint);
}
[Fact]
public void ReplaceContents_ManifestOrderSurvivesLaterCreateObjects()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
table.ReplaceContents(player, new[]
{
new ContainerContentEntry(0xA01u, 0u),
new ContainerContentEntry(0xA02u, 1u),
});
table.Ingest(FullWeenie(0xA02u, container: player, type: ItemType.Container));
table.Ingest(FullWeenie(0xA01u, container: player));
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot);
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot);
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
}
[Fact]
public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots()
{
var table = new ClientObjectTable();
const uint pack = 0x500000C9u;
table.ReplaceContents(pack, new uint[] { 0xA01u, 0xA02u });
table.Ingest(FullWeenie(0xA03u, container: pack));
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(pack));
Assert.Equal(-1, table.Get(0xA03u)!.ContainerSlot);
}
[Fact]
public void ReplaceContents_ZeroContainer_NoOp()
{
@ -516,4 +651,22 @@ public sealed class ClientObjectTableTests
Assert.Equal(2, o.ContainerSlot); // and slot
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped
}
[Fact]
public void UpdateInt64Property_setsValueAndFiresUpdated()
{
var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject { ObjectId = 0x960u });
int updates = 0;
table.ObjectUpdated += _ => updates++;
Assert.True(table.UpdateInt64Property(0x960u, 2u, 12_345L));
Assert.Equal(12_345L, table.Get(0x960u)!.Properties.Int64s[2u]);
Assert.Equal(1, updates);
}
[Fact]
public void UpdateInt64Property_unknownObject_false()
=> Assert.False(new ClientObjectTable().UpdateInt64Property(0xDEADu, 2u, 1L));
}

View file

@ -0,0 +1,30 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class ItemUseabilityTests
{
[Fact]
public void TargetedUse_hasHighTargetBits()
{
const uint healthKit = 0x000A0008u; // source contained, target self or contained
Assert.True(ItemUseability.IsTargeted(healthKit));
Assert.True(ItemUseability.AllowsSelfTarget(healthKit));
Assert.Equal(ItemUseability.Contained, ItemUseability.SourceFlags(healthKit));
Assert.Equal(ItemUseability.Self | ItemUseability.Contained, ItemUseability.TargetFlags(healthKit));
}
[Fact]
public void DirectContainedUse_isNotTargeted()
{
Assert.False(ItemUseability.IsTargeted(ItemUseability.Contained));
Assert.True(ItemUseability.IsDirectUseable(ItemUseability.Contained));
}
[Fact]
public void UseableNo_isNotDirectUseable()
{
Assert.False(ItemUseability.IsDirectUseable(ItemUseability.No));
}
}

View file

@ -1,3 +1,4 @@
using AcDream.Core.Items;
using AcDream.Core.Player;
namespace AcDream.Core.Tests.Player;
@ -244,4 +245,179 @@ public sealed class LocalPlayerStateTests
// Now MaxApprox = 0 + 200 = 200; percent = 100/200 = 0.5.
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
}
[Fact]
public void OnProperties_ClonesBundleAndExposesInt64()
{
var s = new LocalPlayerState();
var props = new PropertyBundle();
props.Ints[0x19u] = 126;
props.Int64s[1u] = 1_234_567_890L;
s.OnProperties(props);
props.Ints[0x19u] = 1;
props.Int64s[1u] = 2L;
Assert.Equal(126, s.Properties.GetInt(0x19u));
Assert.Equal(1_234_567_890L, s.Properties.GetInt64(1u));
}
[Fact]
public void OnSkillUpdate_StoresFormulaAdjustedCurrent()
{
var s = new LocalPlayerState();
int changed = 0;
s.CharacterChanged += () => changed++;
s.OnSkillUpdate(
skillId: 24u,
ranks: 12u,
status: 2u,
xp: 3456u,
init: 30u,
resistance: 0u,
lastUsed: 1.5,
formulaBonus: 80u);
var run = s.GetSkill(24u);
Assert.NotNull(run);
Assert.Equal(122u, run!.Value.CurrentLevel);
Assert.Equal(2u, run.Value.Status);
Assert.Equal(3456u, run.Value.Xp);
Assert.Equal(1, changed);
}
[Fact]
public void ApplySkillTraining_PromotesUntrainedSkillAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
int changed = 0;
s.CharacterChanged += () => changed++;
s.OnSkillUpdate(
skillId: 21u,
ranks: 0u,
status: 1u,
xp: 0u,
init: 5u,
resistance: 0u,
lastUsed: 0,
formulaBonus: 10u);
changed = 0;
Assert.True(s.ApplySkillTraining(21u));
var skill = s.GetSkill(21u);
Assert.NotNull(skill);
Assert.Equal(2u, skill!.Value.Status);
Assert.Equal(1, changed);
}
[Fact]
public void ApplySkillRaise_AddsRanksAndSpentXp()
{
var s = new LocalPlayerState();
s.OnSkillUpdate(
skillId: 24u,
ranks: 12u,
status: 2u,
xp: 3456u,
init: 30u,
resistance: 0u,
lastUsed: 0,
formulaBonus: 80u);
Assert.True(s.ApplySkillRaise(24u, amount: 10u, xpSpent: 500u));
var run = s.GetSkill(24u);
Assert.NotNull(run);
Assert.Equal(22u, run!.Value.Ranks);
Assert.Equal(3956u, run.Value.Xp);
Assert.Equal(132u, run.Value.CurrentLevel);
}
[Fact]
public void ApplyAttributeRaise_AddsRanksAndSpentXp()
{
var s = new LocalPlayerState();
s.OnAttributeUpdate(atType: 5u, ranks: 10u, start: 10u, xp: 100u);
Assert.True(s.ApplyAttributeRaise(atType: 5u, amount: 1u, xpSpent: 25u));
var focus = s.GetAttribute(LocalPlayerState.AttributeKind.Focus);
Assert.NotNull(focus);
Assert.Equal(11u, focus!.Value.Ranks);
Assert.Equal(125u, focus.Value.Xp);
Assert.Equal(21u, focus.Value.Current);
}
[Fact]
public void ApplyVitalRaise_AddsRanksAndSpentXpWithoutChangingCurrent()
{
var s = new LocalPlayerState();
s.OnVitalUpdate(vitalId: 7u, ranks: 5u, start: 10u, xp: 100u, current: 12u);
Assert.True(s.ApplyVitalRaise(vitalId: 1u, amount: 1u, xpSpent: 25u));
var health = s.Get(LocalPlayerState.VitalKind.Health);
Assert.NotNull(health);
Assert.Equal(6u, health!.Value.Ranks);
Assert.Equal(125u, health.Value.Xp);
Assert.Equal(12u, health.Value.Current);
}
[Fact]
public void DebitIntProperty_Present_DebitsClampsAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
var props = new PropertyBundle();
props.Ints[0x18u] = 3;
s.OnProperties(props);
int changed = 0;
s.CharacterChanged += () => changed++;
Assert.True(s.DebitIntProperty(0x18u, 2));
Assert.Equal(1, s.Properties.GetInt(0x18u));
Assert.True(s.DebitIntProperty(0x18u, 5)); // over-debit clamps at 0
Assert.Equal(0, s.Properties.GetInt(0x18u));
Assert.Equal(2, changed);
}
[Fact]
public void DebitIntProperty_Absent_FalseAndNoEvent()
{
var s = new LocalPlayerState();
int changed = 0;
s.CharacterChanged += () => changed++;
Assert.False(s.DebitIntProperty(0x18u, 1));
Assert.Equal(0, changed);
}
[Fact]
public void DebitInt64Property_Present_DebitsAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
var props = new PropertyBundle();
props.Int64s[2u] = 500L;
s.OnProperties(props);
int changed = 0;
s.CharacterChanged += () => changed++;
Assert.True(s.DebitInt64Property(2u, 100L));
Assert.Equal(400L, s.Properties.GetInt64(2u));
Assert.Equal(1, changed);
}
[Fact]
public void DebitInt64Property_ZeroOrAbsent_False()
{
var s = new LocalPlayerState();
Assert.False(s.DebitInt64Property(2u, 100L)); // absent
var props = new PropertyBundle();
props.Int64s[2u] = 0L;
s.OnProperties(props);
Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking
}
}