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:
commit
01594b4cfd
95 changed files with 17201 additions and 167 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ContentProfile>).</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs
Normal file
31
src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs
Normal file
38
src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Core.Net/Messages/SetStackSize.cs
Normal file
38
src/AcDream.Core.Net/Messages/SetStackSize.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue