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

@ -65,7 +65,10 @@ public static class GameEventWiring
// D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the
// toolbar can populate itself at login without keeping a parser reference.
// Optional so all existing callers and tests compile unchanged.
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null)
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null,
// B-Wire: the local player's server guid. When provided, the PD handler upserts
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -235,17 +238,65 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.WieldObject, e =>
{
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is not null) items.MoveItem(
if (p is null) return;
items.MoveItem(
p.Value.ItemGuid,
newContainerId: p.Value.WielderGuid,
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
});
dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
{
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid,
newSlot: (int)p.Value.Placement);
if (p is null) return;
items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement);
items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move
});
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
// opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and
// record the new entries in order (ContainerSlot = index — ACE writes entries
// OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController)
// repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents.
dispatcher.Register(GameEventType.ViewContents, e =>
{
var p = GameEvents.ParseViewContents(e.Payload.Span);
if (p is null) return;
var guids = new uint[p.Value.Items.Count];
for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid;
items.ReplaceContents(p.Value.ContainerGuid, guids);
});
// B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
// to the world. Unparent it from its container (it's now a ground object) so
// the inventory grid drops the cell; the object itself survives.
dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e =>
{
var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u);
});
// B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move.
// Snap the item back to its pre-move slot. Log only when there was no pending move
// (a server-initiated failure on a non-optimistic path).
dispatcher.Register(GameEventType.InventoryServerSaveFailed, e =>
{
var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span);
if (p is null) return;
// B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot.
if (!items.RollbackMove(p.Value.ItemGuid))
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)");
});
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
// view. No table change (the view is UI-only, wired in container-open); log.
dispatcher.Register(GameEventType.CloseGroundContainer, e =>
{
var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span);
if (guid is not null)
Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}");
});
dispatcher.Register(GameEventType.IdentifyObjectResponse, e =>
{
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
@ -289,6 +340,13 @@ public static class GameEventWiring
Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}");
if (p is null) return;
// B-Wire: deliver the player's OWN properties to the player ClientObject.
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
// CreateObject; the player's own stats legitimately come from PD.) Upsert
// because PD can arrive before the player's CreateObject. Retires AP-48/AP-49.
if (playerGuid is not null)
items.UpsertProperties(playerGuid(), p.Value.Properties);
// K-fix13 (2026-04-26): build attrId → current map while
// iterating attributes so the skill-formula resolver below
// can apply (attr1.current * mult1 + attr2.current * mult2)

View file

@ -17,11 +17,23 @@ public static class DeleteObject
{
public const uint Opcode = 0xF747u;
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
/// <param name="FromPickup">
/// <see langword="true"/> when this delete was sourced from a
/// <c>PickupEvent (0xF74A)</c> — the object left the 3-D world view but
/// the weenie record still exists (it is moving into a container).
/// <see langword="false"/> (default) when sourced from <c>DeleteObject
/// (0xF747)</c> — the weenie was destroyed and must be evicted from
/// <see cref="AcDream.Core.Items.ClientObjectTable"/>.
/// Retail two-table model: PickupEvent removes from <c>object_table</c>;
/// DeleteObject removes from <c>weenie_object_table</c>.
/// </param>
public readonly record struct Parsed(uint Guid, ushort InstanceSequence, bool FromPickup = false);
/// <summary>
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
/// 4-byte opcode, matching every other parser in this namespace.
/// The returned <see cref="Parsed.FromPickup"/> is always <see langword="false"/>
/// — this parser handles true destroys only.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
@ -34,6 +46,6 @@ public static class DeleteObject
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
return new Parsed(guid, instanceSequence);
return new Parsed(guid, instanceSequence, FromPickup: false);
}
}

View file

@ -343,19 +343,47 @@ public static class GameEvents
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
}
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.</summary>
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.
/// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid,
/// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed
/// vs holtburger events.rs fixture (slot=3 type=1).</summary>
public readonly record struct InventoryPutObjInContainer(
uint ItemGuid,
uint ContainerGuid,
uint Placement);
uint Placement,
uint ContainerType);
public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan<byte> payload)
{
if (payload.Length < 12) return null;
if (payload.Length < 16) return null;
return new InventoryPutObjInContainer(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
}
/// <summary>0x0196 ViewContents: full contents list of a container you opened.
/// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count.
/// Client consumer: ClientUISystem::OnViewContents (PackableList&lt;ContentProfile&gt;).</summary>
public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType);
public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList<ViewContentsEntry> Items);
public static ViewContents? ParseViewContents(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
int pos = 8;
if ((long)payload.Length - pos < (long)count * 8) return null;
var items = new ViewContentsEntry[count];
for (int i = 0; i < count; i++)
{
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
items[i] = new ViewContentsEntry(guid, type);
}
return new ViewContents(containerGuid, items);
}
// ── Other small-payload events ──────────────────────────────────────────
@ -374,11 +402,17 @@ public static class GameEvents
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.</summary>
public static uint? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
/// <summary>0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op.
/// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger
/// events.rs:147 reads both fields.</summary>
public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError);
public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
if (payload.Length < 8) return null;
return new InventoryServerSaveFailed(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x0052 CloseGroundContainer: server closed a ground container view.</summary>

View file

@ -22,6 +22,9 @@ public static class InventoryActions
public const uint AddShortcutOpcode = 0x019Cu;
public const uint RemoveShortcutOpcode = 0x019Du;
public const uint TeleToPoiOpcode = 0x00B1u;
public const uint GetAndWieldItemOpcode = 0x001Au;
public const uint DropItemOpcode = 0x001Bu;
public const uint NoLongerViewingContentsOpcode = 0x0195u;
/// <summary>
/// Merge stack A into stack B of the same item type. Server validates
@ -95,17 +98,20 @@ public static class InventoryActions
return body;
}
/// <summary>Pin an item / spell to a quickbar slot.</summary>
/// <summary>Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32),
/// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive
/// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0.</summary>
public static byte[] BuildAddShortcut(
uint seq, uint slotIndex, uint objectType, uint targetId)
uint seq, uint index, uint objectGuid, ushort spellId, ushort layer)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), slotIndex);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectType);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), targetId);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer);
return body;
}
@ -130,4 +136,39 @@ public static class InventoryActions
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), poiId);
return body;
}
/// <summary>Drop an item on the ground. ACE GameActionDropItem.Handle reads 1 u32.</summary>
public static byte[] BuildDropItem(uint seq, uint itemGuid)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), DropItemOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
return body;
}
/// <summary>Equip an item from inventory onto the doll. holtburger actions.rs
/// GetAndWieldItemActionData = (itemGuid, equipMask).</summary>
public static byte[] BuildGetAndWieldItem(uint seq, uint itemGuid, uint equipMask)
{
byte[] body = new byte[20];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), GetAndWieldItemOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), equipMask);
return body;
}
/// <summary>Close a side-pack / ground-container view. holtburger actions.rs = (containerGuid).</summary>
public static byte[] BuildNoLongerViewingContents(uint seq, uint containerGuid)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), NoLongerViewingContentsOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid);
return body;
}
}

View file

@ -0,0 +1,31 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>InventoryRemoveObject (0x0024)</c> — a top-level GameMessage (UIQueue),
/// NOT a GameEvent. The server tells the client an object left its inventory view
/// (given away / sold / destroyed). The client drops it from object maintenance.
///
/// <para>Wire layout (ACE <c>GameMessageInventoryRemoveObject.cs</c>, size hint 8):</para>
/// <code>
/// u32 opcode = 0x0024
/// u32 guid
/// </code>
/// </summary>
public static class InventoryRemoveObject
{
public const uint Opcode = 0x0024u;
public readonly record struct Parsed(uint Guid);
/// <summary>Parse a raw 0x0024 body. Returns null on opcode mismatch / truncation.</summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 8) return null; // 4 + 4
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]);
return new Parsed(guid);
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>PrivateUpdatePropertyInt (0x02CD)</c> — the server updates one
/// <c>PropertyInt</c> on the player's OWN object. Unlike the sibling
/// <see cref="PublicUpdatePropertyInt"/> (0x02CE), this carries NO guid (it
/// implicitly targets the local player). Burden (<c>EncumbranceVal</c>, PropertyInt 5)
/// changes ride this opcode on every pick-up / drop.
///
/// <para>Wire layout (ACE <c>GameMessagePrivateUpdatePropertyInt</c>):</para>
/// <code>
/// u32 opcode = 0x02CD
/// u8 sequence // single byte (ByteSequence) — not honored, latest-wins (DR-4)
/// u32 property // PropertyInt enum
/// i32 value
/// </code>
/// </summary>
public static class PrivateUpdatePropertyInt
{
public const uint Opcode = 0x02CDu;
public readonly record struct Parsed(uint Property, int Value);
/// <summary>Parse a raw 0x02CD body. Returns null on opcode mismatch / truncation.</summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 13) return null; // 4 + 1 + 4 + 4
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
int pos = 4;
pos += 1; // sequence byte (not honored)
uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
return new Parsed(prop, value);
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>SetStackSize (0x0197)</c> — a top-level GameMessage (UIQueue group),
/// NOT a GameEvent. The server updates a stack's count + value after a merge / split.
/// Client consumer: ACCWeenieObject::ServerSaysSetStackSize.
///
/// <para>Wire layout (ACE <c>GameMessageSetStackSize.cs</c>, size hint 17):</para>
/// <code>
/// u32 opcode = 0x0197
/// u8 sequence // ByteSequence (UpdatePropertyInt) — not honored
/// u32 guid
/// i32 stackSize
/// i32 value
/// </code>
/// </summary>
public static class SetStackSize
{
public const uint Opcode = 0x0197u;
public readonly record struct Parsed(uint Guid, int StackSize, int Value);
/// <summary>Parse a raw 0x0197 body. Returns null on opcode mismatch / truncation.</summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
int pos = 4;
pos += 1; // sequence byte (not honored)
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
int stack = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); pos += 4;
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
return new Parsed(guid, stack, value);
}
}

View file

@ -5,7 +5,9 @@ namespace AcDream.Core.Net;
/// <summary>
/// Wires WorldSession GameMessage-level object events into the client object
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
/// = evict, PublicUpdatePropertyInt (0x02CE) UiEffects = live icon re-composite.
/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject
/// (0x0024) = inventory-view removal.
/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
/// </summary>
@ -16,18 +18,44 @@ public static class ObjectTableWiring
/// on <paramref name="session"/>. Call this BEFORE the render handler subscribes
/// to EntitySpawned so the table is populated before the render path runs.
/// </summary>
public static void Wire(WorldSession session, ClientObjectTable table)
public static void Wire(WorldSession session, ClientObjectTable table, Func<uint>? playerGuid = null)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(table);
session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
session.EntityDeleted += d => table.Remove(d.Guid);
// Retail two-table model:
// PickupEvent (0xF74A) → object left the 3-D world view; the weenie persists
// (it is moving into a container, e.g. unwield-to-pack). Remove the 3-D
// render entity only — do NOT evict from ClientObjectTable.
// DeleteObject (0xF747) → weenie is DESTROYED; evict from both tables.
// FromPickup = true guards the weenie eviction so it only fires for true destroys.
session.EntityDeleted += d => { if (!d.FromPickup) table.Remove(d.Guid); };
// B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just
// UiEffects — the server is the authority on object properties. UpdateIntProperty
// stores it in the bundle and still mirrors UiEffects → the typed Effects field.
session.ObjectIntPropertyUpdated += u =>
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
// B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the
// local player. Route it to the player object so live EncumbranceVal updates the
// burden bar. The player ClientObject is created at login by the PD UpsertProperties
// call (which precedes any live 0x02CD), so UpdateIntProperty finds it. If it somehow
// hasn't yet, this no-ops (UpdateIntProperty returns false on an unknown guid) rather
// than creating a phantom — the next PD / CreateObject seeds it.
session.PlayerIntPropertyUpdated += u =>
{
if (u.Property == ClientObjectTable.UiEffectsPropertyId)
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
if (playerGuid is not null)
table.UpdateIntProperty(playerGuid(), u.Property, u.Value);
};
// B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value);
// B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory
// view; drop it from the table (retail ClientUISystem removes it from maintenance).
session.InventoryObjectRemoved += guid => table.Remove(guid);
}
/// <summary>Translate the wire spawn into the table's merge patch.</summary>

View file

@ -196,6 +196,25 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<ObjectIntPropertyUpdate>? ObjectIntPropertyUpdated;
/// <summary>Payload for <see cref="PlayerIntPropertyUpdated"/>: a PropertyInt change on
/// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire).</summary>
public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value);
/// <summary>Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one
/// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar.</summary>
public event Action<PlayerIntPropertyUpdate>? PlayerIntPropertyUpdated;
/// <summary>Payload for <see cref="StackSizeUpdated"/>: SetStackSize (0x0197) — a stack's
/// count + value after a merge / split.</summary>
public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value);
/// <summary>Fires when the session parses a SetStackSize (0x0197) top-level GameMessage.</summary>
public event Action<StackSizeUpdate>? StackSizeUpdated;
/// <summary>Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left
/// the player's inventory view.</summary>
public event Action<uint>? InventoryObjectRemoved;
/// <summary>
/// Fires when the server sends a PlayerTeleport (0xF751) game message,
/// signalling that the player is entering portal space. The uint payload
@ -817,16 +836,18 @@ public sealed class WorldSession : IDisposable
}
else if (op == PickupEvent.Opcode)
{
// ACE sends PickupEvent (0xF74A) instead of DeleteObject
// when a player picks up a world item (Player_Tracking
// .RemoveTrackedObject with fromPickup=true). Downstream
// view-removal semantics are identical, so we adapt to
// DeleteObject.Parsed and reuse the existing handler.
// ACE sends PickupEvent (0xF74A) when an object leaves the 3-D world view
// (player picks it up, or a player unwields gear that goes back to their pack).
// The WEENIE is NOT destroyed — it is moving into a container. We adapt to
// DeleteObject.Parsed so the render/entity layer (GameWindow.OnLiveEntityDeleted)
// still removes the 3-D WorldEntity, but FromPickup = true tells ObjectTableWiring
// to skip the ClientObjectTable eviction (retail two-table model: PickupEvent
// targets object_table only; DeleteObject 0xF747 targets weenie_object_table).
var parsed = PickupEvent.TryParse(body);
if (parsed is not null)
EntityDeleted?.Invoke(
new DeleteObject.Parsed(
parsed.Value.Guid, parsed.Value.InstanceSequence));
parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true));
}
else if (op == UpdateMotion.Opcode)
{
@ -987,6 +1008,25 @@ public sealed class WorldSession : IDisposable
ObjectIntPropertyUpdated?.Invoke(
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
}
else if (op == PrivateUpdatePropertyInt.Opcode)
{
var p = PrivateUpdatePropertyInt.TryParse(body);
if (p is not null)
PlayerIntPropertyUpdated?.Invoke(
new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value));
}
else if (op == SetStackSize.Opcode)
{
var p = SetStackSize.TryParse(body);
if (p is not null)
StackSizeUpdated?.Invoke(
new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value));
}
else if (op == InventoryRemoveObject.Opcode)
{
var p = InventoryRemoveObject.TryParse(body);
if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid);
}
else if (op == GameEventEnvelope.Opcode)
{
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
@ -1169,6 +1209,61 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <summary>Send AddShortcut (0x019C) — pin an item to toolbar slot <paramref name="index"/>.
/// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern.</summary>
public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer));
}
/// <summary>Send RemoveShortcut (0x019D) — clear toolbar slot <paramref name="index"/>.
/// Retail: CM_Character::Event_RemoveShortCut.</summary>
public void SendRemoveShortcut(uint index)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index));
}
/// <summary>Send DropItem (0x001B) — drop an item on the ground.</summary>
public void SendDropItem(uint itemGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
}
/// <summary>Send GetAndWieldItem (0x001A) — equip an item to an equip slot.</summary>
public void SendGetAndWieldItem(uint itemGuid, uint equipMask)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask));
}
/// <summary>Send NoLongerViewingContents (0x0195) — close a container view.</summary>
public void SendNoLongerViewingContents(uint containerGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid));
}
/// <summary>Send Use (0x0036) — open/use an object by guid (e.g. open a container in your
/// inventory). A direct wire send: opening a pack you already hold needs no autowalk, unlike
/// GameWindow.SendUse (the world-interaction path). Retail: CM_Physics::Event_Use.</summary>
public void SendUse(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(InteractRequests.BuildUse(seq, targetGuid));
}
/// <summary>Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
/// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer.</summary>
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
{
uint seq = NextGameActionSequence();
SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement));
}
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
/// <remarks>
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>