merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch

Brings the 134 D.2b UI commits onto the physics/collision development line so
main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open
doors fully passable) + the full collision/streaming/dense-town-FPS arc meet
the paperdoll/inventory work.

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-06-25 12:57:46 +02:00
commit 01594b4cfd
95 changed files with 17201 additions and 167 deletions

View file

@ -0,0 +1,43 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
public class DatWidgetFactoryZOrderTests
{
private static (uint, int, int) NoChrome(uint id) => (0u, 0, 0);
[Fact]
public void ZOrder_HigherZLevel_DrawsBehind()
{
// The gmInventoryUI backdrop (ZLevel 100, ReadOrder 4) must draw BEHIND the panels
// (ZLevel 0, ReadOrder 1): lower ZOrder = drawn first = behind. Before the fix, ZOrder
// came from ReadOrder only, so the backdrop (4) painted over the panels (1).
var backdrop = new ElementInfo { Type = 3, ReadOrder = 4, ZLevel = 100 };
var panel = new ElementInfo { Type = 3, ReadOrder = 1, ZLevel = 0 };
var b = DatWidgetFactory.Create(backdrop, NoChrome, null);
var p = DatWidgetFactory.Create(panel, NoChrome, null);
Assert.True(b!.ZOrder < p!.ZOrder, $"backdrop ZOrder {b.ZOrder} should be < panel ZOrder {p.ZOrder}");
}
[Fact]
public void ZOrder_AllZeroZLevel_EqualsReadOrder()
{
// Regression guard: a vitals-style window (every element ZLevel 0) keeps ZOrder == ReadOrder.
var e = new ElementInfo { Type = 3, ReadOrder = 5, ZLevel = 0 };
var w = DatWidgetFactory.Create(e, NoChrome, null);
Assert.Equal(5, w!.ZOrder);
}
[Fact]
public void ZOrder_SameZLevel_OrderedByReadOrder()
{
// Within one ZLevel, ReadOrder is the tiebreaker (higher ReadOrder draws on top).
var lo = new ElementInfo { Type = 3, ReadOrder = 2, ZLevel = 2 };
var hi = new ElementInfo { Type = 3, ReadOrder = 7, ZLevel = 2 };
var l = DatWidgetFactory.Create(lo, NoChrome, null);
var h = DatWidgetFactory.Create(hi, NoChrome, null);
Assert.True(l!.ZOrder < h!.ZOrder);
}
}

View file

@ -0,0 +1,294 @@
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class DragDropSpineTests
{
// A spy handler used across the spine tests.
private sealed class SpyHandler : IItemListDragHandler
{
public bool AcceptResult = true;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastLift = (list, cell, p); }
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastOver = (list, cell, p); return AcceptResult; }
public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
{ LastDrop = (list, cell, p); }
}
[Fact]
public void Payload_holdsAllFields()
{
var src = new UiItemSlot();
var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src);
Assert.Equal(0x5001u, p.ObjId);
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
Assert.Equal(3, p.SourceSlot);
Assert.Same(src, p.SourceCell);
}
[Fact]
public void UiItemList_registerDragHandler_roundtrips()
{
var list = new UiItemList(_ => (0u, 0, 0));
Assert.Null(list.DragHandler);
var h = new SpyHandler();
list.RegisterDragHandler(h);
Assert.Same(h, list.DragHandler);
}
// ── UiItemSlot drag-source payload/ghost ────────────────────────────────
[Fact]
public void GetDragPayload_emptyCell_isNull()
=> Assert.Null(new UiItemSlot().GetDragPayload());
[Fact]
public void GetDragPayload_boundCell_snapshotsFields()
{
var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar };
cell.SetItem(0x5001u, 0x99u);
var p = Assert.IsType<ItemDragPayload>(cell.GetDragPayload());
Assert.Equal(0x5001u, p.ObjId);
Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind);
Assert.Equal(4, p.SourceSlot);
Assert.Same(cell, p.SourceCell);
}
[Fact]
public void GetDragGhost_emptyCell_isNull()
=> Assert.Null(new UiItemSlot().GetDragGhost());
[Fact]
public void GetDragGhost_boundCell_returnsIconTuple()
{
var cell = new UiItemSlot { Width = 32, Height = 32 };
cell.SetItem(0x5001u, 0x99u);
var g = cell.GetDragGhost();
Assert.NotNull(g);
Assert.Equal(0x99u, g!.Value.tex);
Assert.Equal(32, g.Value.w);
Assert.Equal(32, g.Value.h);
}
// ── cell drop-target: DragEnter overlay + DropReleased dispatch ──────────
private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler()
{
var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless
var h = new SpyHandler();
list.RegisterDragHandler(h);
return (list, list.Cell, h);
}
private static ItemDragPayload SomePayload()
=> new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot());
[Fact]
public void DragEnter_setsAcceptOverlay_whenHandlerAccepts()
{
var (_, cell, h) = ListWithHandler();
h.AcceptResult = true;
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual);
}
[Fact]
public void DragEnter_setsRejectOverlay_whenHandlerRejects()
{
var (_, cell, h) = ListWithHandler();
h.AcceptResult = false;
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
}
[Fact]
public void DragOver_resetsOverlayToNeutral()
{
var (_, cell, h) = ListWithHandler();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual);
}
[Fact]
public void DropReleased_accepted_dispatchesToHandler()
{
var (list, cell, h) = ListWithHandler();
var p = SomePayload();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p));
Assert.NotNull(h.LastDrop);
Assert.Same(list, h.LastDrop!.Value.list);
Assert.Same(cell, h.LastDrop.Value.cell);
Assert.Same(p, h.LastDrop.Value.payload);
}
[Fact]
public void DropReleased_dispatchesToHandler_regardlessOfData0()
{
// Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a
// hit), so the handler is authoritative — it dispatches whether or not Data0 is set.
var (list, cell, h) = ListWithHandler();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
Assert.NotNull(h.LastDrop);
}
[Fact]
public void DragBegin_callsHandlerOnDragLift()
{
var (list, cell, h) = ListWithHandler();
var p = SomePayload();
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p));
Assert.NotNull(h.LastLift);
Assert.Same(list, h.LastLift!.Value.list);
Assert.Same(cell, h.LastLift.Value.cell);
Assert.Same(p, h.LastLift.Value.payload);
}
[Fact]
public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost
cell.Clear(); // simulate the lift emptying the source
Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
}
[Fact]
public void FinishDrag_overNothing_deliversNoDrop_butLiftStands()
{
var root = new UiRoot { Width = 800, Height = 600 };
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
list.Cell.Width = 32; list.Cell.Height = 32;
list.Cell.SetItem(0x5001u, 0x99u);
var h = new SpyHandler();
list.RegisterDragHandler(h);
root.AddChild(list);
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // BeginDrag → OnDragLift
root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space
Assert.NotNull(h.LastLift); // lift happened
Assert.Null(h.LastDrop); // no drop dispatched (off-bar)
Assert.Null(root.DragSource); // cleaned up
}
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
// A bound, hit-testable slot inside a list, sized for the hit-test.
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
{
var root = new UiRoot { Width = 800, Height = 600 };
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
// Tests don't run OnDraw (which sizes the cell), so size the cell explicitly.
list.Cell.Width = 32; list.Cell.Height = 32;
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
root.AddChild(list);
return (root, list, list.Cell);
}
[Fact]
public void BeginDrag_arms_whenPayloadNonNull()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // >3px → promote to drag
Assert.Same(cell, root.DragSource);
Assert.IsType<ItemDragPayload>(root.DragPayload);
}
[Fact]
public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot()
{
var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10);
Assert.Null(root.DragSource); // never armed
}
[Fact]
public void Click_withoutDrag_firesUse()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted
Assert.True(used);
}
[Fact]
public void CompletedDrag_doesNotFireUse()
{
var (root, _, cell) = RootWithBoundSlot(0x5001u);
bool used = false;
cell.Clicked = () => used = true;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // promote to drag
root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click
Assert.False(used);
}
// ── no-handler / orphan-cell DragEnter defaults to Reject (review carry-forward) ──
[Fact]
public void DragEnter_orphanCell_noList_defaultsToReject()
{
var cell = new UiItemSlot(); // no parent list → FindList() null
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
}
[Fact]
public void DragEnter_listWithoutHandler_defaultsToReject()
{
var list = new UiItemList(_ => (1u, 1, 1)); // no RegisterDragHandler
list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual);
}
// ── item drag inside a Draggable window (the LIVE toolbar topology) ──────
// Regression (visual gate 2026-06-20): the slot sits inside the Draggable toolbar
// frame, so FindWindow returns the frame. An OCCUPIED slot must start an ITEM drag
// (IsDragSource), NOT move the window; an EMPTY slot falls through to whole-window
// drag (IA-12) so the bar stays movable by its empty cells / chrome. The earlier
// RootWithBoundSlot tests put the slot directly under the root (no draggable
// ancestor), so they could not catch this.
private static (UiRoot root, UiPanel frame, UiItemList list) DraggableFrameWithSlot(uint itemId)
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 5, Top = 5, Width = 32, Height = 32 };
list.Cell.Width = 32; list.Cell.Height = 32;
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
frame.AddChild(list);
root.AddChild(frame);
return (root, frame, list);
}
[Fact]
public void OccupiedSlotInsideDraggableWindow_armsItemDrag_doesNotMoveWindow()
{
var (root, frame, list) = DraggableFrameWithSlot(0x5001u);
// Slot screen rect = frame(10,300)+list(5,5) → (15,305)..(47,337). Press inside, drag >3px.
root.OnMouseDown(UiMouseButton.Left, 20, 310);
root.OnMouseMove(40, 310);
Assert.Same(list.Cell, root.DragSource); // item drag armed
Assert.Equal(10f, frame.Left); // window did NOT move
Assert.Equal(300f, frame.Top);
}
[Fact]
public void EmptySlotInsideDraggableWindow_movesWindow_notItemDrag()
{
var (root, frame, _) = DraggableFrameWithSlot(0u); // empty slot → not a drag source
root.OnMouseDown(UiMouseButton.Left, 20, 310);
root.OnMouseMove(40, 310);
Assert.Null(root.DragSource); // no item drag
Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
Assert.Equal(300f, frame.Top); // y unchanged (310-10=300)
}
}

View file

@ -0,0 +1,528 @@
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
public class InventoryControllerTests
{
private const uint Player = 0x50000001u;
// UiElement is abstract — a concrete, parameterless stand-in for the root + caption hosts
// (the real captions are Type-0 → UiDatElement; in tests we only need a host that holds children).
private sealed class TestElement : UiElement { }
// Element ids (spec §1).
private const uint ContentsGrid = 0x100001C6u;
private const uint ContainerList = 0x100001CAu;
private const uint TopContainer = 0x100001C9u;
private const uint BurdenMeter = 0x100001D9u;
private const uint BurdenText = 0x100001D8u;
private const uint BurdenCaption = 0x100001D7u;
private const uint ContentsCaption = 0x100001C5u;
private const uint ContentsScrollbar = 0x100001C7u;
private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
UiItemList top, UiMeter meter, UiElement burdenText,
UiElement burdenCap, UiElement contentsCap) BuildLayout()
{
var grid = new UiItemList { Width = 192, Height = 96 };
var containers = new UiItemList { Width = 36, Height = 252 };
var top = new UiItemList { Width = 36, Height = 36 };
var meter = new UiMeter { Width = 11, Height = 58 };
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 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);
var byId = new Dictionary<uint, UiElement>
{
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
[ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar,
};
return (new ImportedLayout(root, byId), grid, containers, top, meter,
burdenText, burdenCap, contentsCap);
}
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)
=> InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => strength, datFont: null,
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)));
// 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)
{
t.AddOrUpdate(new ClientObject { ObjectId = bag, Type = ItemType.Container, ItemsCapacity = itemsCapacity });
t.MoveItem(bag, Player, slot);
}
// Place an object in a container at a slot via the faithful production path:
// AddOrUpdate registers it, MoveItem sets ContainerId+ContainerSlot and calls Reindex
// (the same machinery server-driven moves use), so GetContents returns it slot-ordered.
private static void SeedContained(ClientObjectTable t, uint guid, uint container, int slot,
int burden = 0, ItemType type = ItemType.None)
{
t.AddOrUpdate(new ClientObject { ObjectId = guid, Burden = burden, Type = type });
t.MoveItem(guid, container, slot);
}
[Fact]
public void Populate_fills_contents_grid_with_loose_items_only()
{
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
// Seed via the indexed path: AddOrUpdate registers the object, MoveItem places it in
// the container at a slot (the faithful "server moved item into container" flow that
// calls Reindex). GetContents — retail's per-container list — then returns them ordered.
SeedContained(objects, 0xA, Player, slot: 0, burden: 10); // loose
SeedContained(objects, 0xB, Player, slot: 1, burden: 20); // loose
SeedContained(objects, 0xC, Player, slot: 2, type: ItemType.Container); // side bag
Bind(layout, objects);
Assert.Equal(102, grid.GetNumUIItems()); // 2 loose + 100 empty (main-pack capacity)
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
Assert.Equal(0xBu, grid.GetItem(1)!.ItemId);
Assert.Equal(0u, grid.GetItem(2)!.ItemId); // padded empty
Assert.Equal(7, containers.GetNumUIItems()); // 1 side bag + 6 empty (no capacity → 7-slot column)
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
}
[Fact]
public void Equipped_items_are_excluded_from_the_grid_and_selector()
{
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0); // loose pack item
// A self-wielded item: MoveItem with an equip location indexes it under the player
// (the live wield path), but it must NOT show in the pack grid or the selector.
objects.AddOrUpdate(new ClientObject { ObjectId = 0xD });
objects.MoveItem(0xD, Player, newSlot: 1, newEquipLocation: EquipMask.MeleeWeapon);
Bind(layout, objects);
Assert.Equal(102, grid.GetNumUIItems()); // 1 loose + 101 empty (main-pack capacity)
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
Assert.Equal(7, containers.GetNumUIItems()); // 7 empty slots (no bags; the equipped item isn't here)
Assert.Equal(0u, containers.GetItem(0)!.ItemId);
}
[Fact]
public void Grid_is_six_columns_thirtytwo_px()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
Bind(layout, new ClientObjectTable());
Assert.Equal(6, grid.Columns);
Assert.Equal(32f, grid.CellWidth);
Assert.Equal(32f, grid.CellHeight);
}
[Fact]
public void ObjectAdded_for_player_item_rebuilds_grid()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
Bind(layout, objects);
Assert.Equal(102, grid.GetNumUIItems()); // empty grid still shows the full 102-slot pack
Assert.Equal(0u, grid.GetItem(0)!.ItemId); // slot 0 empty before the add
objects.Ingest(new WeenieData(0xA, "Sword", ItemType.MeleeWeapon, 1, 0, 0, 0, 0,
null, null, null, 5, Player, null, null, null, null, null, null, null, null, null));
Assert.Equal(102, grid.GetNumUIItems()); // still 102; the item fills slot 0
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
}
[Fact]
public void Burden_meter_fill_and_percent_from_load()
{
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
var objects = new ClientObjectTable();
// Str 100 → capacity 15000. Carried 7500 → load 0.5 → fill 0.1667, "50%".
objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 7500 });
Bind(layout, objects, strength: 100);
Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3);
Assert.True(meter.Vertical);
// The % text caption child reads "50%".
Assert.Contains("50%", CaptionText(burdenText));
}
[Fact]
public void Captions_render_known_strings()
{
var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
Bind(layout, new ClientObjectTable());
Assert.Contains("Burden", CaptionText(burdenCap));
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
}
[Fact]
public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
{
// B-Wire: the burden bar must read the server's wire EncumbranceVal (PropertyInt 5),
// NOT the client-side carried-Burden sum (retires AP-48 — the sum is only the fallback).
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
var objects = new ClientObjectTable();
// Str 100 → capacity 15000. A carried item sums to 3000 (would read 20%), but the
// server says EncumbranceVal = 7500 → load 0.5 → fill 0.1667 → "50%". Wire wins.
objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 3000 });
var bundle = new PropertyBundle();
bundle.Ints[5] = 7500; // EncumbranceVal (PropertyInt 5)
objects.UpsertProperties(Player, bundle);
Bind(layout, objects, strength: 100);
Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3); // 7500/15000/3 (wire), not 3000-based 0.0667
Assert.Contains("50%", CaptionText(burdenText)); // not "20%"
}
[Fact]
public void Live_player_int_update_refreshes_burden()
{
// B-Wire C1d: a live PrivateUpdatePropertyInt (0x02CD) for the player's EncumbranceVal
// fires ObjectUpdated on the PLAYER object; Concerns now includes o.ObjectId == player,
// so the burden bar repaints. Without the C1d branch this update would be ignored.
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
var objects = new ClientObjectTable();
var bundle = new PropertyBundle();
bundle.Ints[5] = 3000; // initial EncumbranceVal → load 0.2 → "20%"
objects.UpsertProperties(Player, bundle);
Bind(layout, objects, strength: 100);
Assert.Contains("20%", CaptionText(burdenText));
objects.UpdateIntProperty(Player, 5u, 9000); // live 0x02CD: → load 0.6 → "60%"
Assert.Contains("60%", CaptionText(burdenText));
Assert.Equal(0.2f, meter.Fill() ?? -1f, 3); // 9000/15000/3
}
[Fact]
public void Contents_grid_scrollbar_binds_to_the_grid_scroll_model()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
Bind(layout, new ClientObjectTable());
var bar = (UiScrollbar)layout.FindElement(ContentsScrollbar)!;
Assert.Same(grid.Scroll, bar.Model); // the bar drives the grid's scroll
}
[Fact]
public void Contents_grid_pads_empty_slots_to_main_pack_capacity()
{
// The grid shows the full main-pack capacity (default 102) — occupied + empty — so the
// pack reads like retail's fixed 102-slot grid you scroll, not just the loose items.
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Player, ItemsCapacity = 102 });
SeedContained(objects, 0xA, Player, slot: 0, burden: 5); // one loose item
Bind(layout, objects);
Assert.Equal(102, grid.GetNumUIItems()); // 1 item + 101 empty frames = 102
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
Assert.Equal(0u, grid.GetItem(50)!.ItemId); // a padded empty slot
}
[Fact]
public void Side_bag_column_pads_empty_slots_up_to_capacity()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Player, ContainersCapacity = 3 });
SeedContained(objects, 0xC, Player, slot: 0, type: ItemType.Container); // one side bag
Bind(layout, objects);
Assert.Equal(3, containers.GetNumUIItems()); // 1 bag + 2 empty = capacity 3
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); // the bag
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // empty frame
Assert.Equal(0u, containers.GetItem(2)!.ItemId); // empty frame
}
[Fact]
public void Empty_sprites_are_applied_to_the_three_lists()
{
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null,
contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu);
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
Assert.Equal(0x06005D9Cu, containers.GetItem(0)!.EmptySprite); // a padded empty side-bag cell
Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell
}
[Fact]
public void ClickSideBag_sendsUse_andSwapsGridToBagContents()
{
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0); // a loose main-pack item
SeedBag(objects, 0xC, slot: 1); // side bag (player slot 1)
SeedContained(objects, 0xB1, 0xC, slot: 0); // a thing already known to be in the bag
var uses = new List<uint>();
Bind(layout, objects, uses: uses);
// The side-bag column's first cell holds the bag guid; click it.
containers.GetItem(0)!.Clicked!();
Assert.Contains(0xCu, uses); // Use(bag) sent
Assert.Equal(0xB1u, grid.GetItem(0)!.ItemId); // grid swapped to the bag's contents
Assert.Equal(24, grid.GetNumUIItems()); // padded to the bag's ItemsCapacity
}
[Fact]
public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack()
{
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0); // loose main-pack item
SeedBag(objects, 0xC, slot: 1);
SeedContained(objects, 0xB1, 0xC, slot: 0);
var uses = new List<uint>();
var closes = new List<uint>();
var ctrl = Bind(layout, objects, uses: uses, closes: closes);
containers.GetItem(0)!.Clicked!(); // open the bag
top.GetItem(0)!.Clicked!(); // click the main-pack cell
Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent
Assert.DoesNotContain(Player, uses); // no Use for the main pack
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack
Assert.Equal(102, grid.GetNumUIItems());
}
[Fact]
public void SwitchBetweenTwoBags_closesPrevious_opensNext()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC1, slot: 0);
SeedBag(objects, 0xC2, slot: 1);
var uses = new List<uint>();
var closes = new List<uint>();
Bind(layout, objects, uses: uses, closes: closes);
containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1)
containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2)
Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B)
Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack)
}
[Fact]
public void OpenBag_marksTriangleAndSquare_onTheBagCell()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC, slot: 0);
Bind(layout, objects, uses: new List<uint>());
containers.GetItem(0)!.Clicked!();
Assert.True(containers.GetItem(0)!.IsOpenContainer); // triangle on the open bag
Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item
}
[Fact]
public void ClickGridItem_movesSquareOnly_noWire_keepsOpenContainer()
{
var (layout, grid, _, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0);
var uses = new List<uint>();
var closes = new List<uint>();
Bind(layout, objects, uses: uses, closes: closes);
grid.GetItem(0)!.Clicked!(); // select the loose item
Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item
Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged)
Assert.Empty(uses); // selection sends no wire
Assert.Empty(closes);
}
[Fact]
public void Default_mainPackCell_isOpenContainer()
{
var (layout, _, _, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
Bind(layout, objects);
Assert.True(top.GetItem(0)!.IsOpenContainer); // default open container = main pack
}
[Fact]
public void MainPackCell_requestsConstantBackpackIcon_notPlayerBodyIcon() // AP-51 retire
{
var (layout, _, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
// Give the player a (wrong) body icon to prove the main-pack cell does NOT use it.
objects.AddOrUpdate(new ClientObject { ObjectId = Player, IconId = 0x06001234u });
(ItemType type, uint icon)? mainPackCall = null;
InventoryController.Bind(layout, objects, () => Player,
iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; },
strength: () => 100, datFont: null);
// Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons
// IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22).
Assert.NotNull(mainPackCall);
Assert.Equal(ItemType.Container, mainPackCall!.Value.type);
Assert.Equal(0x0600127Eu, mainPackCall.Value.icon);
}
[Fact]
public void SideBagCell_capacityBar_reflectsContents()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a side bag (cap 24)
for (uint i = 0; i < 6; i++) SeedContained(objects, 0xB0u + i, 0xC, slot: (int)i); // 6 items inside
Bind(layout, objects);
// Retail UpdateCapacityDisplay: fill = contained / itemsCapacity = 6/24 = 0.25 (exact in float).
Assert.Equal(0.25f, containers.GetItem(0)!.CapacityFill);
}
[Fact]
public void LooseGridItem_hasNoCapacityBar()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0); // a loose, non-container item
Bind(layout, objects);
Assert.Equal(-1f, grid.GetItem(0)!.CapacityFill); // hidden — not a container
}
private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot());
[Fact]
public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0);
SeedContained(objects, 0xB, Player, slot: 1);
var puts = new List<(uint, uint, int)>();
var ctrl = Bind(layout, objects, puts: puts);
// Drag a fresh item 0xFFFF from elsewhere; drop on the grid cell holding 0xB (slot 1).
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1
((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu));
Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container
Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant)
}
[Fact]
public void Drop_onEmptyGridCell_appendsToFirstEmpty()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0); // 1 loose item → first empty = slot 1
var puts = new List<(uint, uint, int)>();
var ctrl = Bind(layout, objects, puts: puts);
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0)
((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xFFFFu));
Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty
}
[Fact]
public void Drop_onSideBagCell_movesIntoThatBag()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24);
var puts = new List<(uint, uint, int)>();
var ctrl = Bind(layout, objects, puts: puts);
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag)
((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu));
Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0)
Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId);
}
[Fact]
public void OnDragOver_fullSideBag_rejects_butGridAccepts()
{
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 1); // capacity 1...
SeedContained(objects, 0xB0, 0xC, slot: 0); // ...and already full
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
var ctrl = (IItemListDragHandler)Bind(layout, objects);
Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red
Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
}
[Fact]
public void OnDragLift_isNoOp_itemStaysUntilServerConfirms()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0);
var ctrl = (IItemListDragHandler)Bind(layout, objects);
((IItemListDragHandler)ctrl).OnDragLift(grid, grid.GetItem(0)!, Payload(0xAu));
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // NOT removed on lift (unlike the toolbar)
}
[Fact]
public void OnDragOver_closedBag_unknownCount_advisoryAccepts() // AP-61
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a bag with capacity but NO indexed contents (closed)
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
var ctrl = (IItemListDragHandler)Bind(layout, objects);
// GetContents(0xC) is empty (a closed bag's contents aren't indexed until opened) → not known-full → accept.
Assert.True(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu)));
}
[Fact]
public void Drop_thenServerRollback_revertsTheMove() // optimistic + InventoryServerSaveFailed snap-back
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 3); // item starts in the main pack at slot 3
SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24);
var ctrl = Bind(layout, objects);
((IItemListDragHandler)ctrl).HandleDropRelease(containers, containers.GetItem(0)!, Payload(0xAu));
Assert.Equal(0xCu, objects.Get(0xAu)!.ContainerId); // moved into the bag optimistically (instant)
objects.RollbackMove(0xAu); // server rejected (InventoryServerSaveFailed)
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // snapped back to the main pack
Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); // and the original slot
}
// Reads the text of the UiText caption child attached by the controller.
private static string CaptionText(UiElement host)
{
foreach (var c in host.Children)
if (c is UiText t)
{
var lines = t.LinesProvider();
if (lines.Count > 0) return lines[0].Text;
}
return "";
}
}

View file

@ -0,0 +1,89 @@
using System;
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Real-dat smoke test for the gmInventoryUI frame (0x21000023) import + the sub-window
/// mount. Locks the #145-continuation fix: the mount keeps each mounted panel slot's OWN
/// frame ZLevel instead of inheriting the sub-window root's ZLevel 1000. Before the fix the
/// merge's zero-wins-base rule gave the slots ZLevel 1000 → the #145 ZOrder fold
/// (ReadOrder ZLevel·10000) sank them to ≈ 10,000,000, BEHIND the frame's Alphablend
/// backdrop (ZLevel 100 → ≈ 1,000,000), and the backdrop washed out the panels' captions,
/// burden meter, and item cells. Skips when the live dat directory is absent (CI).
/// </summary>
public class InventoryFrameImportProbe
{
private const uint Frame = 0x21000023u;
private const uint Backdrop = 0x100001D0u; // full-window Alphablend backdrop (ZLevel 100)
private const uint BackpackPanel = 0x100001CEu; // mounted gmBackpackUI slot
private const uint ItemsPanel = 0x100001CFu; // mounted gm3DItemsUI slot
private const uint PaperdollPanel = 0x100001CDu; // mounted gmPaperDollUI slot
private const uint BurdenMeter = 0x100001D9u; // backpack content (proves the mount attached it)
private const uint ContentsGrid = 0x100001C6u; // 3D-items content
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 Paperdoll_equip_slots_resolve_to_item_lists()
{
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);
// A representative spread across the slot grid (head, shield, the weapon composite, cloak,
// trinket, a finger). All inherit base 0x100001E4 → 0x2100003D (Type 0x10000031 = UIElement_ItemList).
foreach (uint id in new[] { 0x100005ABu, 0x100001E1u, 0x100001DFu, 0x100005E9u, 0x1000058Eu, 0x100001DCu })
{
var el = layout!.FindElement(id);
Assert.True(el is UiItemList,
$"equip slot 0x{id:X8} resolved to {el?.GetType().Name ?? "null"}, expected UiItemList");
}
}
[Fact]
public void Mounted_panels_sit_in_front_of_the_backdrop()
{
var datDir = DatDir();
if (datDir is null) return; // CI: no live dat — skip (this is a smoke test)
using var dats = new DatCollection(datDir, DatAccessType.Read);
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
Assert.NotNull(layout);
var backdrop = layout!.FindElement(Backdrop);
Assert.NotNull(backdrop); // the full-window backdrop is a direct child of the frame
// The sub-window mount brings each panel's content into the by-id index.
Assert.NotNull(layout.FindElement(BurdenMeter)); // backpack burden meter
Assert.NotNull(layout.FindElement(ContentsGrid)); // 3D-items contents grid
// The fix: every mounted panel slot must draw IN FRONT of the backdrop
// (higher ZOrder = painted later = on top), so the backdrop sits behind the content.
foreach (var (id, name) in new[]
{
(BackpackPanel, "backpack"), (ItemsPanel, "3D-items"), (PaperdollPanel, "paperdoll"),
})
{
var panel = layout.FindElement(id);
Assert.True(panel is not null, $"{name} panel 0x{id:X8} missing from the imported tree");
Assert.True(panel!.ZOrder > backdrop!.ZOrder,
$"{name} panel ZOrder {panel.ZOrder} must be > backdrop ZOrder {backdrop.ZOrder} " +
"(else the Alphablend backdrop overpaints/washes out the panel content)");
}
}
}

View file

@ -0,0 +1,75 @@
using System;
using System.IO;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Real-dat test for <see cref="ItemListCellTemplate.ResolveEmptySprite"/>: the inventory
/// item-lists must resolve their empty-slot sprite from the dat cell template (attribute
/// 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty), NOT the generic toolbar
/// square 0x060074CF. Also PRINTS the resolved ids — the "pin the exact asset" record.
/// Skips when the live dat directory is absent (CI).
/// </summary>
public class ItemListCellTemplateTests
{
private const uint Generic = 0x060074CFu; // generic toolbar-shortcut empty square (the bug)
private const uint ContentsLayout = 0x21000021u; private const uint ContentsGrid = 0x100001C6u;
private const uint BackpackLayout = 0x21000022u; private const uint SideBag = 0x100001CAu;
private const uint MainPack = 0x100001C9u;
private readonly ITestOutputHelper _out;
public ItemListCellTemplateTests(ITestOutputHelper o) => _out = o;
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 Inventory_lists_resolve_a_real_nongeneric_empty_sprite()
{
var datDir = DatDir();
if (datDir is null) return; // CI: no live dat — skip (smoke test)
using var dats = new DatCollection(datDir, DatAccessType.Read);
foreach (var (name, layout, elem) in new[]
{
("contents", ContentsLayout, ContentsGrid),
("side-bag", BackpackLayout, SideBag),
("main-pack", BackpackLayout, MainPack),
})
{
uint sprite = ItemListCellTemplate.ResolveEmptySprite(dats, layout, elem);
_out.WriteLine($"{name}: list 0x{elem:X8} (layout 0x{layout:X8}) -> empty sprite 0x{sprite:X8}");
Assert.True(sprite != 0u, $"{name}: resolved 0 (no 0x1000000e attr / prototype / media)");
Assert.True(sprite != Generic, $"{name}: resolved the generic 0x060074CF — the bug, not the fix");
Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id");
}
}
[Fact]
public void Inventory_lists_resolve_the_pinned_retail_sprites()
{
var datDir = DatDir();
if (datDir is null) return; // CI: no live dat — skip
using var dats = new DatCollection(datDir, DatAccessType.Read);
// Pinned from the live dat + the 2026-06-22 visual gate. Contents = the 32x32 prototype's
// ItemSlot_Empty; the 36x36 container/main-pack resolve THROUGH inheritance to their inner
// ItemSlot_Empty (NOT the 0x06005D9C open-container triangle the first cut grabbed — a
// structural-only assertion let that wrong-but-valid 0x06 sprite pass, so we pin exacts).
Assert.Equal(0x06004D20u, ItemListCellTemplate.ResolveEmptySprite(dats, ContentsLayout, ContentsGrid));
Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, SideBag));
Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, MainPack));
}
}

View file

@ -0,0 +1,148 @@
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
public class PaperdollControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x40000005u;
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
private sealed class RootElement : UiElement { }
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
{
var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot };
var lists = new Dictionary<uint, UiItemList>();
var byId = new Dictionary<uint, UiElement>();
var root = new RootElement { Width = 224, Height = 214 };
foreach (var id in ids)
{
var list = new UiItemList { Width = 32, Height = 32 };
lists[id] = list; byId[id] = list; root.AddChild(list);
}
return (new ImportedLayout(root, byId), lists);
}
private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects,
List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u)
=> PaperdollController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0x1234u,
sendWield: wields is null ? null : (i, m) => wields.Add((i, m)),
emptySlotSprite: emptySlot);
private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc)
{
t.AddOrUpdate(new ClientObject { ObjectId = guid, WielderId = Player });
t.MoveItem(guid, Player, newSlot: -1, newEquipLocation: loc);
}
private static void SeedPackItem(ClientObjectTable t, uint guid, EquipMask validLocations)
{
t.AddOrUpdate(new ClientObject { ObjectId = guid, ValidLocations = validLocations });
t.MoveItem(guid, Pack, newSlot: 0);
}
[Fact]
public void Populate_shows_equipped_item_in_its_slot()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
Bind(layout, objects);
Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId); // helm in the head slot
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
}
[Fact]
public void Populate_matches_a_weapon_into_the_composite_slot()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedEquipped(objects, 0xB01u, EquipMask.MeleeWeapon); // a sword's actual equip loc
Bind(layout, objects);
Assert.Equal(0xB01u, lists[WeaponSlot].Cell.ItemId); // matched via (loc & composite) != 0
}
[Fact]
public void OnDragOver_accepts_only_valid_locations()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedPackItem(objects, 0xC01u, EquipMask.HeadWear); // a helm in the pack
var ctrl = Bind(layout, objects);
var payload = new ItemDragPayload(0xC01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
Assert.True(ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK
Assert.False(ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO
}
[Fact]
public void HandleDropRelease_wields_optimistically_and_sends_wire()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedPackItem(objects, 0xD01u, EquipMask.HeadWear);
var wields = new List<(uint item, uint mask)>();
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload);
Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly
Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId)
Assert.Single(wields);
Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire
}
[Fact]
public void HandleDropRelease_resolves_the_finger_bit_for_a_dual_finger_ring()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedPackItem(objects, 0xE01u, EquipMask.FingerWearLeft | EquipMask.FingerWearRight);
var wields = new List<(uint item, uint mask)>();
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xE01u, ItemDragSource.Inventory, 0, lists[FingerLSlot].Cell);
ctrl.HandleDropRelease(lists[FingerLSlot], lists[FingerLSlot].Cell, payload);
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
}
[Fact]
public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2)
{
var (layout, lists) = BuildLayout();
Bind(layout, new ClientObjectTable(), emptySlot: 0x06004D20u);
Assert.Equal(0x06004D20u, lists[HeadSlot].Cell.EmptySprite);
}
[Fact]
public void Live_player_wield_repaints_the_slot() // event-driven: ObjectMoved → Concerns → Populate
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
Bind(layout, objects); // slots empty at bind
SeedPackItem(objects, 0xF01u, EquipMask.HeadWear); // a helm appears in the pack (not on the doll)
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId);
objects.WieldItemOptimistic(0xF01u, Player, EquipMask.HeadWear); // player wields it → ObjectMoved(to=player)
Assert.Equal(0xF01u, lists[HeadSlot].Cell.ItemId); // the head slot repainted with the helm
}
[Fact]
public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
Bind(layout, objects);
const uint npc = 0x60000001u;
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0xF02u, WielderId = npc, CurrentlyEquippedLocation = EquipMask.HeadWear,
});
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); // the NPC's helm is NOT on the player's doll
}
}

View file

@ -89,8 +89,8 @@ public class ToolbarControllerTests
ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g);
// UiEvent is a positional record struct: (SourceId, Target, Type, Data0..3, Payload)
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.MouseDown));
// Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation.
slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(0x5001u, used);
}
@ -421,4 +421,177 @@ public class ToolbarControllerTests
Assert.Equal(callsAfterBind, iconCallCount); // unchanged
}
// ── B.1: drag-drop spine wiring ──────────────────────────────────────────
/// <summary>Bind registers the controller as each slot list's drag handler and
/// stamps every cell's SlotIndex + SourceKind=ShortcutBar.</summary>
[Fact]
public void Bind_registersDragHandler_andStampsSlots()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
for (int i = 0; i < Row1.Length; i++)
{
Assert.Same(ctrl, slots[Row1[i]].DragHandler);
Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex);
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind);
}
// Bottom row slots are indices 9..17; same handler + source kind as the top row.
for (int j = 0; j < Row2.Length; j++)
{
Assert.Same(ctrl, slots[Row2[j]].DragHandler);
Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex);
Assert.Equal(ItemDragSource.ShortcutBar, slots[Row2[j]].Cell.SourceKind);
}
}
/// <summary>OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible)
/// is Stream B.2; the stub accepts any non-empty payload.</summary>
[Fact]
public void OnDragOver_acceptsRealItem()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
}
/// <summary>OnDragOver rejects a ghost payload with ObjId 0 (the guard against an
/// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem.</summary>
[Fact]
public void OnDragOver_rejectsZeroObjId()
{
var (layout, slots, _) = FakeToolbar();
var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(),
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.False(ctrl.OnDragOver(list, list.Cell, payload));
}
// ── B.2: live drag handler (store + reorder/remove + wire) ───────────────
private static (System.Collections.Generic.List<(uint i,uint g)> adds,
System.Collections.Generic.List<uint> removes) NewSpies(out System.Action<uint,uint> add, out System.Action<uint> rem)
{
var adds = new System.Collections.Generic.List<(uint,uint)>();
var removes = new System.Collections.Generic.List<uint>();
add = (i, g) => adds.Add((i, g));
rem = i => removes.Add(i);
return (adds, removes);
}
[Fact]
public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
var (adds, removes) = NewSpies(out var add, out var rem);
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem);
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId);
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
Assert.Contains(3u, removes);
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
}
[Fact]
public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0),
new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
var (adds, removes) = NewSpies(out var add, out var rem);
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem);
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload);
Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId);
Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId);
Assert.Equal(new[] { 3u, 5u }, removes.ToArray());
Assert.Contains((5u, 0x5001u), adds);
Assert.Contains((3u, 0x5002u), adds);
}
[Fact]
public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
var (adds, removes) = NewSpies(out var add, out var rem);
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem);
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload);
ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload);
Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId);
Assert.Equal(0u, slots[Row1[3]].Cell.ItemId);
Assert.Contains((7u, 0x5001u), adds);
Assert.DoesNotContain(adds, a => a.i == 3u);
}
[Fact]
public void HandleDropRelease_ontoSelf_reAddsToSource()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u });
var shortcuts = new System.Collections.Generic.List<PlayerDescriptionParser.ShortcutEntry>
{ new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) };
var (adds, removes) = NewSpies(out var add, out var rem);
var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts,
iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { },
sendAddShortcut: add, sendRemoveShortcut: rem);
var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell);
ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); // slot 3 emptied
ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3
Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op)
Assert.Contains((3u, 0x5001u), adds); // AddShortcut(3, A) sent
Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift]
Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place]
}
[Fact]
public void ToolbarSlots_useGreenCrossAcceptSprite()
{
var (layout, slots, _) = FakeToolbar();
ToolbarController.Bind(layout, new ClientObjectTable(),
() => System.Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
}
}

View file

@ -0,0 +1,22 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
public class LayoutImporterMountTests
{
[Fact]
public void Mounts_ChildlessMediaLessInheritor_WithContentfulBase()
=> Assert.True(LayoutImporter.ShouldMountBaseChildren(derivedChildCount: 0, derivedMediaCount: 0, baseChildCount: 5));
[Fact]
public void DoesNotMount_WhenDerivedHasOwnMedia() // close button / title
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 1, 5));
[Fact]
public void DoesNotMount_WhenDerivedHasOwnChildren()
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(2, 0, 5));
[Fact]
public void DoesNotMount_WhenBaseIsChildless() // vitals/chat/toolbar style prototypes
=> Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 0, 0));
}

View file

@ -0,0 +1,63 @@
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiCollapsibleFrameTests
{
private static UiCollapsibleFrame MakeFrame(out UiPanel row2a, out UiPanel row2b)
{
var f = new UiCollapsibleFrame(_ => (1u, 1, 1))
{
CollapsedHeight = 96f,
ExpandedHeight = 128f,
};
row2a = new UiPanel(); row2b = new UiPanel();
f.SecondRow = new UiElement[] { row2a, row2b };
return f;
}
[Fact]
public void Tick_belowMidpoint_snapsCollapsed_hidesSecondRow()
{
var f = MakeFrame(out var a, out var b);
f.Height = 100f; // nearer the collapsed stop (midpoint 112)
f.TickForTest(0.016);
Assert.Equal(96f, f.Height);
Assert.False(a.Visible);
Assert.False(b.Visible);
Assert.False(f.IsExpanded);
}
[Fact]
public void Tick_aboveMidpoint_snapsExpanded_showsSecondRow()
{
var f = MakeFrame(out var a, out var b);
f.Height = 120f; // nearer the expanded stop
f.TickForTest(0.016);
Assert.Equal(128f, f.Height);
Assert.True(a.Visible);
Assert.True(b.Visible);
Assert.True(f.IsExpanded);
}
[Fact]
public void Tick_notConfigured_isNoOp()
{
var f = new UiCollapsibleFrame(_ => (1u, 1, 1)); // Collapsed==Expanded==0
f.Height = 50f;
f.TickForTest(0.016);
Assert.Equal(50f, f.Height); // unchanged, no divide/no forced height
}
[Fact]
public void HitEdges_respectsResizableEdgesMask_bottomOnly()
{
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
Resizable = true, ResizableEdges = ResizeEdges.Bottom };
// bottom edge (y=200) → Bottom only
Assert.Equal(ResizeEdges.Bottom, UiRoot.HitEdges(panel, 200, 200, 5));
// top edge (y=100) → masked out → None
Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 100, 5));
}
}

View file

@ -0,0 +1,43 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public class UiItemListGridTests
{
[Fact]
public void CellOffset_RowMajor()
{
Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 36, 36));
Assert.Equal((72f, 0f), UiItemList.CellOffset(2, 3, 36, 36));
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
}
[Fact]
public void GridMode_PositionsCellsInColumns()
{
var list = new UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 };
list.Flush(); // drop the ctor's default cell
for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());
var c4 = list.GetItem(4)!;
Assert.Equal(36f, c4.Left);
Assert.Equal(36f, c4.Top);
Assert.Equal(36f, c4.Width);
Assert.Equal(36f, c4.Height);
}
[Fact]
public void FillMode_SizesSingleCellToList()
{
// CellWidth defaults to 0 = "fill the list" (single-cell toolbar legacy).
var list = new UiItemList { Width = 36, Height = 36 };
list.Flush();
list.AddItem(new UiItemSlot());
var c = list.Cell;
Assert.Equal(0f, c.Left);
Assert.Equal(0f, c.Top);
Assert.Equal(36f, c.Width);
Assert.Equal(36f, c.Height);
}
}

View file

@ -0,0 +1,83 @@
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public sealed class UiItemListScrollTests
{
private static UiItemList Grid(int items)
{
var list = new UiItemList { Columns = 6, CellWidth = 32, CellHeight = 32, Width = 192, Height = 96 };
list.Flush(); // drop the default single cell
for (int i = 0; i < items; i++) list.AddItem(new UiItemSlot());
list.LayoutCells(); // drive the scroll model + position cells
return list;
}
[Fact]
public void RowCount_ceils_to_whole_rows()
{
Assert.Equal(0, UiItemList.RowCount(0, 6));
Assert.Equal(1, UiItemList.RowCount(1, 6));
Assert.Equal(7, UiItemList.RowCount(41, 6));
}
[Fact]
public void Grid_drives_scroll_model_from_content()
{
var list = Grid(30); // 5 rows × 32 = 160 content, 96 view
Assert.Equal(160, list.Scroll.ContentHeight);
Assert.Equal(96, list.Scroll.ViewHeight);
Assert.Equal(64, list.Scroll.MaxScroll);
Assert.True(list.Scroll.HasOverflow);
}
[Fact]
public void At_top_first_three_rows_visible_rest_clipped()
{
var list = Grid(30);
Assert.True(list.GetItem(0)!.Visible); // row 0, top 0
Assert.True(list.GetItem(12)!.Visible); // row 2, top 64 (+32 == 96)
Assert.False(list.GetItem(18)!.Visible); // row 3, top 96 (off the bottom)
}
[Fact]
public void Scrolled_one_row_shifts_window_and_clips_top_row()
{
var list = Grid(30);
list.Scroll.SetScrollY(32);
list.LayoutCells();
Assert.False(list.GetItem(0)!.Visible); // row 0 scrolled above
Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 now at the view top
Assert.True(list.GetItem(18)!.Visible); // row 3 now visible
}
[Fact]
public void Scroll_survives_the_per_frame_anchor_pass()
{
// Regression (the escaping-grid bug): UiElement.DrawSelfAndChildren runs ApplyAnchor
// on every child AFTER OnDraw/LayoutCells. Grid cells must be exempt (Anchors=None) so
// the anchor pass can't reset the scroll offset LayoutCells applied — otherwise the
// grid translates out of the view when scrolled.
var list = Grid(30); // cells added (Anchors=None) + laid out
for (int i = 0; i < list.GetNumUIItems(); i++) // first anchor pass (would capture base)
list.GetItem(i)!.ApplyAnchor(list.Width, list.Height);
list.Scroll.SetScrollY(32); // scroll one row
list.LayoutCells();
for (int i = 0; i < list.GetNumUIItems(); i++) // second anchor pass (the draw traversal)
list.GetItem(i)!.ApplyAnchor(list.Width, list.Height);
Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 stays at the view top
Assert.False(list.GetItem(0)!.Visible); // row 0 stays clipped above
}
[Fact]
public void Wheel_down_scrolls_toward_the_bottom()
{
var list = Grid(30); // max scroll 64, LineHeight 32
// Silk wheel +Y = up/older; UiText negates Data0. Wheel DOWN (Data0 < 0) → +ScrollY.
var e = new UiEvent(0u, null, UiEventType.Scroll, Data0: -1);
bool handled = list.OnEvent(e);
Assert.True(handled);
Assert.Equal(32, list.Scroll.ScrollY); // one line (32px) down
}
}

View file

@ -22,4 +22,25 @@ public class UiItemListTests
var list = new UiItemList();
Assert.Same(list.GetItem(0), list.Cell);
}
[Fact]
public void CellEmptySprite_stamps_existing_and_future_cells()
{
var list = new UiItemList(); // ctor adds one default cell
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // UiItemSlot default before set
list.CellEmptySprite = 0x06004D20u; // a pack-slot sprite
Assert.Equal(0x06004D20u, list.GetItem(0)!.EmptySprite); // existing cell re-stamped
list.AddItem(new UiItemSlot());
Assert.Equal(0x06004D20u, list.GetItem(1)!.EmptySprite); // new cell stamped
}
[Fact]
public void CellEmptySprite_zero_leaves_the_UiItemSlot_default()
{
var list = new UiItemList();
list.CellEmptySprite = 0u;
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default
}
}

View file

@ -141,4 +141,30 @@ public class UiItemSlotTests
s.SetShortcutNum(0, peace: true);
Assert.Null(s.ActiveDigitArray());
}
// ── Container-switching indicator overlays (decomp SetOpenContainerState 0x004e1200 /
// SetSelectedState 0x004e1240) ───────────────────────────────────────────────────────
[Fact]
public void Selected_defaultsFalse() => Assert.False(new UiItemSlot().Selected);
[Fact]
public void IsOpenContainer_defaultsFalse() => Assert.False(new UiItemSlot().IsOpenContainer);
[Fact]
public void SelectedSprite_default_isGreenYellowSquare()
=> Assert.Equal(0x06004D21u, new UiItemSlot().SelectedSprite);
[Fact]
public void OpenContainerSprite_default_isTriangle()
=> Assert.Equal(0x06005D9Cu, new UiItemSlot().OpenContainerSprite);
// ── Container capacity bar (decomp UpdateCapacityDisplay 0x004e16e0, element 0x10000347) ──
[Fact]
public void CapacityFill_defaultsHidden() => Assert.Equal(-1f, new UiItemSlot().CapacityFill);
[Fact]
public void CapacityBackSprite_default() => Assert.Equal(0x06004D22u, new UiItemSlot().CapacityBackSprite);
[Fact]
public void CapacityFrontSprite_default() => Assert.Equal(0x06004D23u, new UiItemSlot().CapacityFrontSprite);
}

View file

@ -0,0 +1,35 @@
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiMeterVerticalTests
{
[Fact]
public void Vertical_fill_from_bottom_occupies_lower_fraction()
{
// 11x58 bar, 50% → fill rect is the bottom 29px.
var (x, y, w, h) = UiMeter.ComputeVFillRect(0.5f, 11f, 58f, fromBottom: true);
Assert.Equal(0f, x);
Assert.Equal(29f, y, 3); // h - h*p = 58 - 29
Assert.Equal(11f, w);
Assert.Equal(29f, h, 3);
}
[Fact]
public void Vertical_fill_from_top_starts_at_zero()
{
var (_, y, _, h) = UiMeter.ComputeVFillRect(0.25f, 11f, 58f, fromBottom: false);
Assert.Equal(0f, y);
Assert.Equal(14.5f, h, 3);
}
[Theory]
[InlineData(-1f, 0f)]
[InlineData(2f, 58f)]
public void Vertical_fill_clamps_fraction(float pct, float expectedH)
{
var (_, _, _, h) = UiMeter.ComputeVFillRect(pct, 11f, 58f, fromBottom: true);
Assert.Equal(expectedH, h, 3);
}
}

View file

@ -157,7 +157,7 @@ public class UiRootInputTests
public void ResizeRect_RightBottom_GrowsSizeOnly()
{
var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40);
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
Assert.Equal(10f, x); Assert.Equal(20f, y);
Assert.Equal(130f, w); Assert.Equal(65f, h);
}
@ -168,11 +168,32 @@ public class UiRootInputTests
// Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40,
// origin shifts so the RIGHT edge (110) stays put → x = 70.
var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40);
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
Assert.Equal(40f, w);
Assert.Equal(70f, x);
}
[Fact]
public void ResizeRect_Bottom_ClampsToMaxH()
{
// dy=1000 on a 50-tall window with maxH=128 → height clamps to 128, origin unchanged.
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Bottom, dx: 0, dy: 1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
Assert.Equal(128f, h);
Assert.Equal(20f, y);
}
[Fact]
public void ResizeRect_Top_ClampsToMaxH()
{
// Drag the top edge UP by 1000 on a 50-tall window with maxH=128 → height clamps to 128,
// origin shifts so the bottom edge (70) stays put → y = 20 + 50 - 128.
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
ResizeEdges.Top, dx: 0, dy: -1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
Assert.Equal(128f, h);
Assert.Equal(20f + 50f - 128f, y);
}
[Fact]
public void HitEdges_DetectsCornerAndInteriorNone()
{
@ -236,4 +257,77 @@ public class UiRootInputTests
Assert.Equal(8f, x); Assert.Equal(24f, y);
Assert.Equal(200f, w); Assert.Equal(14f, h);
}
[Fact]
public void ToggleWindow_FlipsVisible_AndReturnsNewState()
{
var root = new UiRoot { Width = 800, Height = 600 };
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
root.AddChild(win);
root.RegisterWindow("inventory", win);
Assert.True(root.ToggleWindow("inventory")); // hidden -> shown
Assert.True(win.Visible);
Assert.False(root.ToggleWindow("inventory")); // shown -> hidden
Assert.False(win.Visible);
}
[Fact]
public void ShowHideWindow_SetVisibility_UnknownNameIsNoOp()
{
var root = new UiRoot { Width = 800, Height = 600 };
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
root.AddChild(win);
root.RegisterWindow("inventory", win);
Assert.True(root.ShowWindow("inventory"));
Assert.True(win.Visible);
Assert.True(root.HideWindow("inventory"));
Assert.False(win.Visible);
Assert.False(root.ShowWindow("nope"));
Assert.False(root.HideWindow("nope"));
Assert.False(root.ToggleWindow("nope"));
}
[Fact]
public void ShowWindow_RaisesAbovePeers()
{
var root = new UiRoot { Width = 800, Height = 600 };
var a = new UiPanel { Width = 100, Height = 100 };
var b = new UiPanel { Width = 100, Height = 100 };
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
root.AddChild(a); root.AddChild(b); root.AddChild(win);
root.RegisterWindow("inventory", win);
root.ShowWindow("inventory");
Assert.True(win.ZOrder > a.ZOrder);
Assert.True(win.ZOrder > b.ZOrder);
}
[Fact]
public void BringToFront_SetsStrictlyGreatestZOrder()
{
var root = new UiRoot { Width = 800, Height = 600 };
var a = new UiPanel { Width = 100, Height = 100, ZOrder = 5 };
var b = new UiPanel { Width = 100, Height = 100, ZOrder = 9 };
root.AddChild(a); root.AddChild(b);
root.BringToFront(a);
Assert.True(a.ZOrder > b.ZOrder); // 10 > 9
}
[Fact]
public void MouseDown_OnWindow_RaisesItAbovePeers()
{
var root = new UiRoot { Width = 800, Height = 600 };
// 'peer' has a higher ZOrder but does NOT cover (50,50); 'clicked' does.
var peer = new UiPanel { Left = 300, Top = 0, Width = 100, Height = 100, ZOrder = 9 };
var clicked = new UiPanel { Left = 0, Top = 0, Width = 100, Height = 100, ZOrder = 0, Draggable = true };
root.AddChild(peer); root.AddChild(clicked);
root.OnMouseDown(UiMouseButton.Left, 50, 50); // only 'clicked' occupies (50,50)
Assert.True(clicked.ZOrder > peer.ZOrder);
root.OnMouseUp(UiMouseButton.Left, 50, 50);
}
}

View file

@ -120,6 +120,25 @@ public sealed class GameEventWiringTests
Assert.Equal(0x2000u, item.ContainerId);
}
[Fact]
public void WireAll_WieldObject_ConfirmsOptimisticWield()
{
var (d, items, _, _, _) = MakeAll();
const uint player = 0x2000u;
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];
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
}
[Fact]
public void WireAll_PopupString_RoutesToChatLog()
{
@ -496,4 +515,150 @@ public sealed class GameEventWiringTests
Assert.Equal(0x5001u, got![0].ObjectGuid);
}
[Fact]
public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject()
{
// PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler
// must land it in the player ClientObject so the burden bar reads the wire value.
const uint playerGuid = 0x50000001u;
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(),
new ChatLog(), playerGuid: () => playerGuid);
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0x00000001u); // propertyFlags = PropertyInt32
w.Write(0x52u); // weenieType
// int table: u16 count, u16 buckets, then key/val pairs
w.Write((ushort)1); // count
w.Write((ushort)8); // buckets (ignored)
w.Write(5u); // key = EncumbranceVal
w.Write(1500u); // val
// vector + has_health (no vector blocks)
w.Write(0u); // vectorFlags = None
w.Write(0u); // has_health
// strict trailer
w.Write(0u); // option_flags = None
w.Write(0u); // options1
w.Write(0u); // legacy hotbar count
w.Write(0u); // spellbook_filters
w.Write(0u); // inventory count
w.Write(0u); // equipped count
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
var player = items.Get(playerGuid);
Assert.NotNull(player);
Assert.Equal(1500, player!.Properties.Ints[5]);
}
[Fact]
public void WireAll_ViewContents_RecordsMembershipInClientObjectTable()
{
var (d, items, _, _, _) = MakeAll();
// containerGuid 0xC9 + 2 entries
byte[] payload = new byte[8 + 2 * 8];
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
d.Dispatch(env!.Value);
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
}
[Fact]
public void WireAll_ViewContents_IsFullReplace_DropsRemovedItems()
{
var (d, items, _, _, _) = MakeAll();
// First view of container 0xC9: two items.
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A01u, 0x50000A02u });
Assert.Equal(2, items.GetContents(0x500000C9u).Count);
// Second view: only one item — the other was removed server-side. Additive merge would keep both.
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A02u });
Assert.Equal(new[] { 0x50000A02u }, items.GetContents(0x500000C9u));
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
}
private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids)
{
byte[] payload = new byte[8 + itemGuids.Length * 8];
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), containerGuid);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)itemGuids.Length);
for (int i = 0; i < itemGuids.Length; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 8), itemGuids[i]);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12 + i * 8), 0u); // containerType
}
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
d.Dispatch(env!.Value);
}
[Fact]
public void WireAll_InventoryServerSaveFailed_RollsBackOptimisticMove()
{
var (d, items, _, _, _) = MakeAll();
// An item known to be in container 0xC0 at slot 2, then optimistically moved to 0xC1.
items.RecordMembership(0x50000B01u, containerId: 0x500000C0u);
items.MoveItem(0x50000B01u, 0x500000C0u, 2);
items.MoveItemOptimistic(0x50000B01u, 0x500000C1u, 0);
Assert.Equal(0x500000C1u, items.Get(0x50000B01u)!.ContainerId); // moved (optimistic)
// Server bounces it: InventoryServerSaveFailed (itemGuid, weenieError).
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B01u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x1Au); // some WeenieError
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryServerSaveFailed, payload));
d.Dispatch(env!.Value);
Assert.Equal(0x500000C0u, items.Get(0x50000B01u)!.ContainerId); // snapped back
Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot);
}
[Fact]
public void WireAll_InventoryPutObjInContainer_ConfirmsOptimisticMove_soNoRollback()
{
var (d, items, _, _, _) = MakeAll();
items.RecordMembership(0x50000B02u, containerId: 0x500000C0u);
items.MoveItem(0x50000B02u, 0x500000C0u, 2);
items.MoveItemOptimistic(0x50000B02u, 0x500000C1u, 0); // optimistic move (pending snapshot)
// Server CONFIRMS via InventoryPutObjInContainer (item, container, placement, containerType).
byte[] payload = new byte[16];
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
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);
}
[Fact]
public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer()
{
var (d, items, _, _, _) = MakeAll();
items.RecordMembership(0x50000A01u, containerId: 0x500000C9u);
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload));
d.Dispatch(env!.Value);
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
}
}

View file

@ -56,15 +56,18 @@ public sealed class AppraiseTests
[Fact]
public void ParsePutObjInContainer_RoundTrip()
{
byte[] payload = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u);
// 4 fields (Task 7 fix: containerType added) — 16 bytes required.
byte[] payload = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType
var parsed = GameEvents.ParsePutObjInContainer(payload);
Assert.NotNull(parsed);
Assert.Equal(0x1001u, parsed!.Value.ItemGuid);
Assert.Equal(0x2001u, parsed.Value.ContainerGuid);
Assert.Equal(3u, parsed.Value.Placement);
Assert.Equal(1u, parsed.Value.ContainerType);
}
}

View file

@ -36,4 +36,42 @@ public sealed class DeleteObjectTests
Assert.Equal(0x80000439u, parsed!.Value.Guid);
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
}
/// <summary>
/// Regression guard: TryParse (0xF747 = true destroy) must always produce
/// FromPickup = false. PickupEvent (0xF74A) is a different opcode and is
/// the ONLY path that sets FromPickup = true (in WorldSession).
/// </summary>
[Fact]
public void TryParse_AlwaysReturnsFromPickupFalse()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000001u);
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x0001);
var parsed = DeleteObject.TryParse(body);
Assert.NotNull(parsed);
Assert.False(parsed!.Value.FromPickup,
"DeleteObject 0xF747 is a true destroy — FromPickup must be false.");
}
/// <summary>
/// FromPickup = true can be constructed via the record directly (as
/// WorldSession does for PickupEvent 0xF74A). Default is false.
/// </summary>
[Fact]
public void Parsed_DefaultFromPickupIsFalse()
{
var p = new DeleteObject.Parsed(0x80000001u, 1);
Assert.False(p.FromPickup);
}
[Fact]
public void Parsed_FromPickupTrueCanBeSet()
{
var p = new DeleteObject.Parsed(0x80000001u, 1, FromPickup: true);
Assert.True(p.FromPickup);
}
}

View file

@ -0,0 +1,75 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class GameEventsInventoryTests
{
[Fact]
public void ParseViewContents_twoEntries_returnsContainerAndItems()
{
// containerGuid + count(2) + 2×{guid, containerType}
var b = new byte[4 + 4 + 2 * 8];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); // containerGuid
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 2u); // count
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 0x50000A01u); // guid 1
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 0u); // type 1 (item)
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(16), 0x50000A02u);// guid 2
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(20), 1u); // type 2 (container)
var p = GameEvents.ParseViewContents(b);
Assert.NotNull(p);
Assert.Equal(0x500000C9u, p!.Value.ContainerGuid);
Assert.Equal(2, p.Value.Items.Count);
Assert.Equal(0x50000A01u, p.Value.Items[0].Guid);
Assert.Equal(0u, p.Value.Items[0].ContainerType);
Assert.Equal(0x50000A02u, p.Value.Items[1].Guid);
Assert.Equal(1u, p.Value.Items[1].ContainerType);
}
[Fact]
public void ParseViewContents_zeroCount_returnsEmptyList()
{
var b = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u);
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0u);
var p = GameEvents.ParseViewContents(b);
Assert.NotNull(p);
Assert.Empty(p!.Value.Items);
}
[Fact]
public void ParseViewContents_truncated_returnsNull()
=> Assert.Null(GameEvents.ParseViewContents(new byte[4]));
[Fact]
public void ParsePutObjInContainer_readsAllFourFields()
{
var b = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x500000C9u); // containerGuid
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 3u); // placement
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 1u); // containerType
var p = GameEvents.ParsePutObjInContainer(b);
Assert.NotNull(p);
Assert.Equal(0x50000A01u, p!.Value.ItemGuid);
Assert.Equal(0x500000C9u, p.Value.ContainerGuid);
Assert.Equal(3u, p.Value.Placement);
Assert.Equal(1u, p.Value.ContainerType);
}
[Fact]
public void ParseInventoryServerSaveFailed_readsGuidAndError()
{
var b = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x0Au); // weenieError
var p = GameEvents.ParseInventoryServerSaveFailed(b);
Assert.NotNull(p);
Assert.Equal(0x50000A01u, p!.Value.ItemGuid);
Assert.Equal(0x0Au, p.Value.WeenieError);
}
}

View file

@ -74,18 +74,25 @@ public sealed class InventoryActionsTests
}
[Fact]
public void BuildAddShortcut_ThreeFields()
public void BuildAddShortcut_ItemShortcut_FieldLayout()
{
byte[] body = InventoryActions.BuildAddShortcut(
seq: 1, slotIndex: 0, objectType: 1, targetId: 0x3E1);
// ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0.
byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0);
Assert.Equal(24, body.Length);
Assert.Equal(InventoryActions.AddShortcutOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0x3E1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index
Assert.Equal(0x3E1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid
Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId
Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer
}
[Fact]
public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s()
{
byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3);
Assert.Equal(0x1234, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20)));
Assert.Equal(3, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22)));
}
[Fact]
@ -106,4 +113,38 @@ public sealed class InventoryActionsTests
Assert.Equal(42u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
}
[Fact]
public void BuildDropItem_layout()
{
byte[] b = InventoryActions.BuildDropItem(seq: 7, itemGuid: 0x50000A01u);
Assert.Equal(16, b.Length);
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
Assert.Equal(0x001Bu, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
}
[Fact]
public void BuildGetAndWieldItem_layout()
{
byte[] b = InventoryActions.BuildGetAndWieldItem(seq: 7, itemGuid: 0x50000A01u, equipMask: 0x02u);
Assert.Equal(20, b.Length);
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
Assert.Equal(0x001Au, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
Assert.Equal(0x02u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(16)));
}
[Fact]
public void BuildNoLongerViewingContents_layout()
{
byte[] b = InventoryActions.BuildNoLongerViewingContents(seq: 7, containerGuid: 0x500000C9u);
Assert.Equal(16, b.Length);
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
}
}

View file

@ -0,0 +1,32 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class InventoryRemoveObjectTests
{
private static byte[] Build(uint guid, uint opcode = 0x0024u)
{
var b = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), guid);
return b;
}
[Fact]
public void TryParse_valid_returnsGuid()
{
var p = InventoryRemoveObject.TryParse(Build(0x50000A07u));
Assert.NotNull(p);
Assert.Equal(0x50000A07u, p!.Value.Guid);
}
[Fact]
public void TryParse_wrongOpcode_returnsNull()
=> Assert.Null(InventoryRemoveObject.TryParse(Build(1, opcode: 0x0023u)));
[Fact]
public void TryParse_truncated_returnsNull()
=> Assert.Null(InventoryRemoveObject.TryParse(new byte[7]));
}

View file

@ -0,0 +1,36 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class PrivateUpdatePropertyIntTests
{
// 0x02CD body: opcode(4) + seq(1) + property(4) + value(4) = 13 bytes. NO guid.
private static byte[] Build(uint property, int value, byte seq = 1, uint opcode = 0x02CDu)
{
var b = new byte[13];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
b[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), property);
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), value);
return b;
}
[Fact]
public void TryParse_encumbranceVal_returnsPropValue()
{
var p = PrivateUpdatePropertyInt.TryParse(Build(property: 5u, value: 1500));
Assert.NotNull(p);
Assert.Equal(5u, p!.Value.Property);
Assert.Equal(1500, p.Value.Value);
}
[Fact]
public void TryParse_wrongOpcode_returnsNull()
=> Assert.Null(PrivateUpdatePropertyInt.TryParse(Build(5, 1, opcode: 0x02CEu)));
[Fact]
public void TryParse_truncated_returnsNull()
=> Assert.Null(PrivateUpdatePropertyInt.TryParse(new byte[12]));
}

View file

@ -0,0 +1,37 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class SetStackSizeTests
{
private static byte[] Build(uint guid, int stackSize, int value, byte seq = 1, uint opcode = 0x0197u)
{
var b = new byte[17];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
b[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid);
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), stackSize);
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value);
return b;
}
[Fact]
public void TryParse_validStack_returnsGuidStackValue()
{
var p = SetStackSize.TryParse(Build(0x50000A01u, stackSize: 25, value: 125));
Assert.NotNull(p);
Assert.Equal(0x50000A01u, p!.Value.Guid);
Assert.Equal(25, p.Value.StackSize);
Assert.Equal(125, p.Value.Value);
}
[Fact]
public void TryParse_wrongOpcode_returnsNull()
=> Assert.Null(SetStackSize.TryParse(Build(1, 1, 1, opcode: 0x0196u)));
[Fact]
public void TryParse_truncated_returnsNull()
=> Assert.Null(SetStackSize.TryParse(new byte[16]));
}

View file

@ -7,10 +7,13 @@ namespace AcDream.Core.Net.Tests;
/// <summary>
/// D.5.4 Task 7 — ObjectTableWiring.
///
/// Integration test is omitted: WorldSession.EntitySpawned has no internal
/// test seam to fire it without a real Tick + packet bytes, so subscription
/// correctness is covered by build (type-checks) + the live run. Only the
/// pure mapping (ToWeenieData) is unit-tested here.
/// WorldSession.EntitySpawned/EntityDeleted have no in-process test seam (the
/// ctor opens a UDP socket), so the subscription wiring is tested via the guard
/// logic extracted as a local handler — this is the same lambda body that
/// ObjectTableWiring.Wire wires to session.EntityDeleted. The retail two-table
/// semantics are: PickupEvent (0xF74A) removes from the 3-D object_table but
/// KEEPS the weenie in ClientObjectTable; DeleteObject (0xF747) evicts the
/// weenie from both. FromPickup = true guards the eviction.
/// </summary>
public sealed class ObjectTableWiringTests
{
@ -94,4 +97,80 @@ public sealed class ObjectTableWiringTests
Assert.Equal(100, d.MaxStructure);
Assert.Equal(4.5f, d.Workmanship);
}
// -------------------------------------------------------------------------
// The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted.
// Testing it directly avoids needing a live WorldSession (UDP socket).
// -------------------------------------------------------------------------
private static Action<DeleteObject.Parsed> MakeEntityDeletedHandler(ClientObjectTable table)
=> d => { if (!d.FromPickup) table.Remove(d.Guid); };
private static WeenieData MinimalWeenie(uint guid) => new(
Guid: guid,
Name: "Test Item",
Type: null,
WeenieClassId: 1u,
IconId: 0x06000001u,
IconOverlayId: 0u,
IconUnderlayId: 0u,
Effects: 0u,
Value: null,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: null,
WielderId: null,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null);
/// <summary>
/// PickupEvent path: FromPickup = true → weenie is RETAINED in ClientObjectTable.
/// The 3-D render entity is removed (EntityDeleted still fires), but the weenie
/// data persists so the follow-up InventoryPutObjInContainer can find it.
/// </summary>
[Fact]
public void EntityDeleted_FromPickupTrue_RetainsWeenieInTable()
{
var table = new ClientObjectTable();
var handler = MakeEntityDeletedHandler(table);
const uint guid = 0x80000100u;
table.Ingest(MinimalWeenie(guid));
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
// Fire EntityDeleted as WorldSession does for PickupEvent (0xF74A).
// The handler itself represents the EntityDeleted subscription — firing it IS the event.
handler(new DeleteObject.Parsed(guid, 0, FromPickup: true));
// Post-condition: weenie still in table (it moved into a container, was NOT destroyed).
// EntityDeleted still fires (the handler ran), so the 3-D render layer would remove the WorldEntity.
Assert.NotNull(table.Get(guid));
}
/// <summary>
/// DeleteObject path: FromPickup = false → weenie IS evicted from ClientObjectTable.
/// Regression guard — true destroys (0xF747) must still clean up the table.
/// </summary>
[Fact]
public void EntityDeleted_FromPickupFalse_EvictsWeenieFromTable()
{
var table = new ClientObjectTable();
var handler = MakeEntityDeletedHandler(table);
const uint guid = 0x80000200u;
table.Ingest(MinimalWeenie(guid));
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
// Fire EntityDeleted as WorldSession does for DeleteObject (0xF747).
handler(new DeleteObject.Parsed(guid, 0, FromPickup: false));
// Post-condition: weenie evicted (truly destroyed).
Assert.Null(table.Get(guid));
}
}

View file

@ -0,0 +1,40 @@
using AcDream.Core.Items;
using Xunit;
namespace AcDream.Core.Tests.Items;
public class BurdenMathTests
{
[Theory]
[InlineData(100, 0, 15000)] // base: str*150
[InlineData(100, 3, 24000)] // +clamp(3*30,0,150)=90 -> +90*100
[InlineData(100, 10, 30000)] // 10*30=300 clamped to 150 -> +150*100
[InlineData(0, 5, 0)] // str<=0 -> 0
public void EncumbranceCapacity_matches_retail(int str, int aug, int expected)
=> Assert.Equal(expected, BurdenMath.EncumbranceCapacity(str, aug));
[Theory]
[InlineData(15000, 7500, 0.5f)]
[InlineData(15000, 0, 0f)]
[InlineData(0, 100, 0f)] // cap<=0 guard
public void LoadRatio_is_burden_over_capacity(int cap, int burden, float expected)
=> Assert.Equal(expected, BurdenMath.LoadRatio(cap, burden), 4);
[Theory]
[InlineData(0f, 0f)]
[InlineData(0.5f, 0.16667f)]
[InlineData(1.0f, 0.33333f)]
[InlineData(3.0f, 1.0f)] // full at 300%
[InlineData(4.0f, 1.0f)] // clamped
public void LoadToFill_is_third_clamped(float load, float expected)
=> Assert.Equal(expected, BurdenMath.LoadToFill(load), 4);
[Theory]
[InlineData(0f, 0)]
[InlineData(0.5f, 50)]
[InlineData(1.0f, 100)]
[InlineData(3.0f, 300)] // 300% = full
[InlineData(4.0f, 300)] // SATURATES at 300% (computed from the clamped fill, decomp 176544-176576)
public void LoadToPercent_saturates_at_300(float load, int expected)
=> Assert.Equal(expected, BurdenMath.LoadToPercent(load));
}

View file

@ -0,0 +1,32 @@
using AcDream.Core.Items;
using Xunit;
namespace AcDream.Core.Tests.Items;
public class ClientObjectTableBurdenTests
{
private const uint Player = 0x50000001u;
[Fact]
public void SumCarriedBurden_sums_pack_sidebag_and_wielded_but_not_unrelated()
{
var t = new ClientObjectTable();
// loose pack item
t.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 100 });
// side bag in pack
t.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player, Burden = 50,
Type = ItemType.Container });
// item inside the side bag (2-deep)
t.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = 0xB, Burden = 30 });
// wielded (ContainerId 0, WielderId = player)
t.AddOrUpdate(new ClientObject { ObjectId = 0xD, WielderId = Player, Burden = 200 });
// unrelated object in the world
t.AddOrUpdate(new ClientObject { ObjectId = 0xE, ContainerId = 0, Burden = 999 });
Assert.Equal(380, t.SumCarriedBurden(Player)); // 100+50+30+200
}
[Fact]
public void SumCarriedBurden_unknown_owner_is_zero()
=> Assert.Equal(0, new ClientObjectTable().SumCarriedBurden(Player));
}

View file

@ -319,6 +319,126 @@ public sealed class ClientObjectTableTests
Assert.True((o.Type & ItemType.Creature) != 0); // LiveItemType(guid) & Creature drives creature targeting
}
[Fact]
public void ReplaceContents_RecordsAllInListOrder()
{
var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u));
Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId);
}
[Fact]
public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge)
{
var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side
Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u));
Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering
Assert.Equal(0u, table.Get(0xA03u)!.ContainerId);
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
}
[Fact]
public void ReplaceContents_ReordersToNewListOrder()
{
var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u });
table.ReplaceContents(0xC9u, new uint[] { 0xA02u, 0xA01u });
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
}
[Fact]
public void ReplaceContents_ZeroContainer_NoOp()
{
var table = new ClientObjectTable();
table.ReplaceContents(0u, new uint[] { 0xA01u });
Assert.Empty(table.GetContents(0u));
Assert.Null(table.Get(0xA01u));
}
[Fact]
public void MoveItemOptimistic_movesAndRemembersPreMove()
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x700u, container: 0xC0u)); table.MoveItem(0x700u, 0xC0u, newSlot: 3);
table.MoveItemOptimistic(0x700u, 0xC1u, 0);
Assert.Equal(0xC1u, table.Get(0x700u)!.ContainerId); // moved now (instant)
Assert.True(table.RollbackMove(0x700u)); // pre-move was remembered
Assert.Equal(0xC0u, table.Get(0x700u)!.ContainerId); // snapped back to the original container
Assert.Equal(3, table.Get(0x700u)!.ContainerSlot); // and slot
}
[Fact]
public void ConfirmMove_clearsPending_soRollbackIsNoOp()
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x701u, container: 0xC0u));
table.MoveItemOptimistic(0x701u, 0xC1u, 0);
table.ConfirmMove(0x701u);
Assert.False(table.RollbackMove(0x701u)); // nothing pending → no rollback
Assert.Equal(0xC1u, table.Get(0x701u)!.ContainerId); // stays at the confirmed container
}
[Fact]
public void MoveItemOptimistic_twice_keepsTheOriginalPreMove()
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x702u, container: 0xC0u));
table.MoveItemOptimistic(0x702u, 0xC1u, 0);
table.MoveItemOptimistic(0x702u, 0xC2u, 0); // a second move before any confirm
table.RollbackMove(0x702u);
Assert.Equal(0xC0u, table.Get(0x702u)!.ContainerId); // rolls back to the FIRST pre-move
}
[Fact]
public void RollbackMove_unknownItem_false()
=> Assert.False(new ClientObjectTable().RollbackMove(0xDEADu));
[Fact]
public void MoveItemOptimistic_twice_oneConfirm_stillRollsBack() // I1: an early confirm must not strand the item
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x910u, container: 0xC0u)); table.MoveItem(0x910u, 0xC0u, 5);
table.MoveItemOptimistic(0x910u, 0xC1u, 0); // move 1 (outstanding 1, snapshot C0/5)
table.MoveItemOptimistic(0x910u, 0xC2u, 0); // move 2 (outstanding 2)
table.ConfirmMove(0x910u); // server confirms move 1 → outstanding 1, STILL pending
Assert.True(table.RollbackMove(0x910u)); // move 2 rejected → can still roll back (not stranded at C2)
Assert.Equal(0xC0u, table.Get(0x910u)!.ContainerId);
Assert.Equal(5, table.Get(0x910u)!.ContainerSlot);
}
[Fact]
public void Clear_dropsPendingMoves_soRecycledGuidIsntMisRolledBack() // I2: pending must not survive a table flush
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x911u, container: 0xC0u));
table.MoveItemOptimistic(0x911u, 0xC1u, 0); // pending snapshot for 0x911
table.Clear(); // teleport/logoff flushes the table
table.Ingest(FullWeenie(0x911u, container: 0xC5u)); // a recycled guid reappears fresh
Assert.False(table.RollbackMove(0x911u)); // no stale pending → no mis-rollback
Assert.Equal(0xC5u, table.Get(0x911u)!.ContainerId);
}
[Fact]
public void MoveItemOptimistic_insertBefore_shiftsOthers_keepsGaplessOrder() // the "items change position" bug
{
var table = new ClientObjectTable();
table.Ingest(FullWeenie(0x920u, container: 0xC0u)); table.MoveItem(0x920u, 0xC0u, 0);
table.Ingest(FullWeenie(0x921u, container: 0xC0u)); table.MoveItem(0x921u, 0xC0u, 1);
table.Ingest(FullWeenie(0x922u, container: 0xC0u)); table.MoveItem(0x922u, 0xC0u, 2);
// Move 0x922 (index 2) to index 0 → order [0x922, 0x920, 0x921], slots renumbered 0,1,2 (no ties,
// no reshuffle). A raw slot-set would leave 0x922 + 0x920 both at slot 0 → unstable sort.
table.MoveItemOptimistic(0x922u, 0xC0u, 0);
Assert.Equal(new[] { 0x922u, 0x920u, 0x921u }, table.GetContents(0xC0u));
Assert.Equal(0, table.Get(0x922u)!.ContainerSlot);
Assert.Equal(1, table.Get(0x920u)!.ContainerSlot);
Assert.Equal(2, table.Get(0x921u)!.ContainerSlot);
}
[Fact]
public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out
{
@ -333,4 +453,67 @@ public sealed class ClientObjectTableTests
Assert.Equal(new[] { 0x541u, 0x540u }, table.GetContents(0xC4u));
Assert.Equal(2, table.GetContents(0xC4u).Count); // no duplicate from the same-container move
}
[Fact]
public void WieldItemOptimistic_equipsInstantly_setsContainerAndEquip()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u, pack = 0x40000005u;
table.AddOrUpdate(new ClientObject { ObjectId = 0x940u });
table.MoveItem(0x940u, pack, newSlot: 2); // a pack item (WielderId stays 0)
table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear);
var o = table.Get(0x940u)!;
Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant)
Assert.Equal(player, o.ContainerId); // contained-by-wielder (matches the WieldObject 0x0023 confirm)
Assert.Equal(0u, o.WielderId); // optimistic wield does NOT write WielderId (ContainerId-based model)
}
[Fact]
public void WieldItemOptimistic_rollback_unequips_andReturnsToPack()
{
var table = new ClientObjectTable();
const uint player = 0x50000001u, pack = 0x40000005u;
table.AddOrUpdate(new ClientObject { ObjectId = 0x941u });
table.MoveItem(0x941u, pack, newSlot: 2);
table.WieldItemOptimistic(0x941u, player, EquipMask.HeadWear);
Assert.True(table.RollbackMove(0x941u)); // server rejected the wield
var o = table.Get(0x941u)!;
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // un-equipped
Assert.Equal(pack, o.ContainerId); // back in the pack
Assert.Equal(2, o.ContainerSlot); // at its original slot
}
[Fact]
public void RollbackMove_restoresPreMoveEquipLocation() // unwield-reject must snap back to EQUIPPED
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
table.AddOrUpdate(new ClientObject { ObjectId = 0x942u });
table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped
table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip)
Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation);
Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield
Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped
}
[Fact]
public void WieldItemOptimistic_unknownItem_false()
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear));
[Fact]
public void WieldThenMove_oneConfirm_rollsBackToPreWield() // outstanding-count across wield+move (shared RecordPending)
{
var table = new ClientObjectTable();
const uint player = 0x50000001u, pack = 0x40000005u, pack2 = 0x40000006u;
table.AddOrUpdate(new ClientObject { ObjectId = 0x950u });
table.MoveItem(0x950u, pack, newSlot: 2); // pre-wield: pack slot 2
table.WieldItemOptimistic(0x950u, player, EquipMask.HeadWear); // outstanding 1, snapshot (pack, 2, None)
table.MoveItemOptimistic(0x950u, pack2, 0); // outstanding 2 (same snapshot)
table.ConfirmMove(0x950u); // confirm the first → outstanding 1, still pending
Assert.True(table.RollbackMove(0x950u)); // the second rejected → roll back
var o = table.Get(0x950u)!;
Assert.Equal(pack, o.ContainerId); // to the ORIGINAL pre-wield container
Assert.Equal(2, o.ContainerSlot); // and slot
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped
}
}

View file

@ -0,0 +1,61 @@
using AcDream.Core.Items;
using Xunit;
namespace AcDream.Core.Tests.Items;
public sealed class ClientObjectTableUpdateTests
{
[Fact]
public void UpsertProperties_unknownObject_createsThenMerges()
{
var t = new ClientObjectTable();
bool added = false;
t.ObjectAdded += _ => added = true;
var bundle = new PropertyBundle();
bundle.Ints[5] = 1234; // EncumbranceVal
t.UpsertProperties(0x50000001u, bundle);
var o = t.Get(0x50000001u);
Assert.NotNull(o);
Assert.Equal(1234, o!.Properties.Ints[5]);
Assert.True(added);
}
[Fact]
public void UpsertProperties_existingObject_mergesAndFiresUpdated()
{
var t = new ClientObjectTable();
t.AddOrUpdate(new ClientObject { ObjectId = 0x50000001u });
bool updated = false;
t.ObjectUpdated += _ => updated = true;
var bundle = new PropertyBundle();
bundle.Ints[5] = 99;
t.UpsertProperties(0x50000001u, bundle);
Assert.Equal(99, t.Get(0x50000001u)!.Properties.Ints[5]);
Assert.True(updated);
}
[Fact]
public void UpdateStackSize_knownObject_setsFieldsAndFiresUpdated()
{
var t = new ClientObjectTable();
t.AddOrUpdate(new ClientObject { ObjectId = 0x600u, StackSize = 1, Value = 5 });
bool updated = false;
t.ObjectUpdated += _ => updated = true;
bool ok = t.UpdateStackSize(0x600u, stackSize: 25, value: 125);
Assert.True(ok);
Assert.Equal(25, t.Get(0x600u)!.StackSize);
Assert.Equal(125, t.Get(0x600u)!.Value);
Assert.True(updated);
}
[Fact]
public void UpdateStackSize_unknownObject_returnsFalse()
=> Assert.False(new ClientObjectTable().UpdateStackSize(0xDEADu, 1, 1));
}

View file

@ -0,0 +1,54 @@
using AcDream.Core.Items;
using Xunit;
namespace AcDream.Core.Tests.Items;
/// <summary>
/// Pins every EquipMask member to the verbatim retail INVENTORY_LOC value
/// (docs/research/named-retail/acclient.h:3193). The wire delivers these exact bits
/// (ACE EquipMask == retail INVENTORY_LOC); any drift desyncs the paperdoll element-id→mask
/// map and the GetAndWieldItem wire. This test is the anti-regression lock.
/// </summary>
public sealed class EquipMaskTests
{
[Theory]
[InlineData(0x00000000u, EquipMask.None)]
[InlineData(0x00000001u, EquipMask.HeadWear)]
[InlineData(0x00000002u, EquipMask.ChestWear)]
[InlineData(0x00000004u, EquipMask.AbdomenWear)]
[InlineData(0x00000008u, EquipMask.UpperArmWear)]
[InlineData(0x00000010u, EquipMask.LowerArmWear)]
[InlineData(0x00000020u, EquipMask.HandWear)]
[InlineData(0x00000040u, EquipMask.UpperLegWear)]
[InlineData(0x00000080u, EquipMask.LowerLegWear)]
[InlineData(0x00000100u, EquipMask.FootWear)]
[InlineData(0x00000200u, EquipMask.ChestArmor)]
[InlineData(0x00000400u, EquipMask.AbdomenArmor)]
[InlineData(0x00000800u, EquipMask.UpperArmArmor)]
[InlineData(0x00001000u, EquipMask.LowerArmArmor)]
[InlineData(0x00002000u, EquipMask.UpperLegArmor)]
[InlineData(0x00004000u, EquipMask.LowerLegArmor)]
[InlineData(0x00008000u, EquipMask.NeckWear)]
[InlineData(0x00010000u, EquipMask.WristWearLeft)]
[InlineData(0x00020000u, EquipMask.WristWearRight)]
[InlineData(0x00040000u, EquipMask.FingerWearLeft)]
[InlineData(0x00080000u, EquipMask.FingerWearRight)]
[InlineData(0x00100000u, EquipMask.MeleeWeapon)]
[InlineData(0x00200000u, EquipMask.Shield)]
[InlineData(0x00400000u, EquipMask.MissileWeapon)]
[InlineData(0x00800000u, EquipMask.MissileAmmo)]
[InlineData(0x01000000u, EquipMask.Held)]
[InlineData(0x02000000u, EquipMask.TwoHanded)]
[InlineData(0x04000000u, EquipMask.TrinketOne)]
[InlineData(0x08000000u, EquipMask.Cloak)]
[InlineData(0x10000000u, EquipMask.SigilOne)]
[InlineData(0x20000000u, EquipMask.SigilTwo)]
[InlineData(0x40000000u, EquipMask.SigilThree)]
public void Member_has_canonical_retail_value(uint expected, EquipMask member)
=> Assert.Equal(expected, (uint)member);
[Fact]
public void Weapon_ready_slot_composite_is_0x3500000() // acclient.h:3235 WEAPON_READY_SLOT_LOC
=> Assert.Equal(0x3500000u,
(uint)(EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded));
}

View file

@ -0,0 +1,39 @@
using AcDream.Core.Items;
using Xunit;
namespace AcDream.Core.Tests.Items;
public class ShortcutStoreTests
{
[Fact]
public void Load_mapsSlotToObjId_skipsEmptyAndOutOfRange()
{
var store = new ShortcutStore();
store.Load(new (int, uint)[]
{
(0, 0x5001u),
(3, 0x5002u),
(5, 0u), // empty/spell → skipped
(99, 0x5003u), // out of range → skipped
});
Assert.Equal(0x5001u, store.Get(0));
Assert.Equal(0x5002u, store.Get(3));
Assert.Equal(0u, store.Get(5));
Assert.True(store.IsEmpty(1));
}
[Fact]
public void SetRemoveGet_roundtrip_andBoundsSafe()
{
var store = new ShortcutStore();
store.Set(4, 0xABCDu);
Assert.Equal(0xABCDu, store.Get(4));
Assert.False(store.IsEmpty(4));
store.Remove(4);
Assert.True(store.IsEmpty(4));
Assert.Equal(0u, store.Get(-1));
Assert.Equal(0u, store.Get(18));
store.Set(18, 1u); // no-op, no throw
store.Remove(-1); // no-op, no throw
}
}