Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The user's connected gate found five issues; each fixed at the root: G4 (the discovery): retail's vendor X button calls only SetVisible(0) (pc:204147-204182) — the SESSION stays open and re-using the vendor lands on the same-session refresh; the range watcher remains the sole real close. Our port invented a full teardown on X, which is exactly why reopening died. The Runtime fixture proves the wire dispatch was never the problem; ACE has no already-open short-circuit. G3 (regression from the drag-suppression fix): denying IsDragSource also dropped press capture, so clicks fell through to window-drag. UiItemSlot.HandlesClick now claims presses for any occupied cell independent of drag eligibility — clickable and draggable are separate concerns. G5: the authored popup 0x21000043 is ONE scrollable column with a real scrollbar subtree (live-dat scan: ListBox 0x10000350 + scrollbar 0x10000351), not a 3x6 grid. UiMenu gains an authored-driven Scrollable mode (wheel, thumb drag, track paging, up/down buttons); chat's menu is untouched and its ten tests prove it. G1: retail's cost format is "%s %hsp (you have %hsp)" — the p after each %hs is a LITERAL pyreal suffix the port swallowed as part of the specifier. Restored. G2: the Buying/Selling pages' authored lists (same cell template as Items) get the empty-slot fill, presentation-only until staging. Clean-room complete solution: 11,390 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
593 lines
24 KiB
C#
593 lines
24 KiB
C#
using System.Buffers.Binary;
|
|
using System.Text;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.Runtime.Tests.Gameplay;
|
|
|
|
/// <summary>
|
|
/// Slice 5.3 — Runtime ownership of the vendor browse session:
|
|
/// <see cref="RuntimeInventoryState.Vendor"/> is populated by the inbound
|
|
/// <c>ApproachVendor</c> (0x0062) route (<c>GameEventWiring.WireAll</c>'s
|
|
/// <c>vendor</c> parameter) and torn down by the shared generation-reset
|
|
/// mechanism (session reset / portal-out / logout).
|
|
/// </summary>
|
|
public sealed class RuntimeVendorLifecycleTests
|
|
{
|
|
private const uint Player = 0x50000001u;
|
|
|
|
[Fact]
|
|
public void ApproachVendorEvent_PopulatesVendorStateWithProfileAndItems()
|
|
{
|
|
using GameRuntime runtime = Create();
|
|
VendorState vendor = runtime.InventoryOwner.Vendor;
|
|
using IDisposable wiring = Wire(vendor);
|
|
|
|
Dispatch(BuildApproachVendorPayload(
|
|
vendorGuid: 0x40001000u,
|
|
categories: 0x42u,
|
|
minValue: 5u,
|
|
maxValue: 500u,
|
|
dealsMagic: true,
|
|
buyPrice: 0.8f,
|
|
sellPrice: 1.3f,
|
|
currencyWcid: 0u,
|
|
currencyAmount: 0u,
|
|
currencyName: "",
|
|
items:
|
|
[
|
|
new VendorItemFixture(
|
|
ItemGuid: 0x50002000u,
|
|
StackSize: 1,
|
|
Name: "Iron Sword",
|
|
WeenieClassId: 42u,
|
|
RawIconId: 0x1234u,
|
|
ItemType: (uint)ItemType.Weapon,
|
|
Value: 250),
|
|
]));
|
|
|
|
Assert.Equal(0x40001000u, vendor.VendorId);
|
|
// Profile fields — including the price-relevant rates (research doc
|
|
// §B.1/§A.2: BuyPrice/SellPrice feed ShopSystem::BuyPrice/SellPrice).
|
|
Assert.Equal(0x42u, vendor.Profile.MerchandiseItemTypes);
|
|
Assert.Equal(5u, vendor.Profile.MerchandiseMinValue);
|
|
Assert.Equal(500u, vendor.Profile.MerchandiseMaxValue);
|
|
Assert.True(vendor.Profile.DealMagicalItems);
|
|
Assert.Equal(0.8f, vendor.Profile.BuyPrice);
|
|
Assert.Equal(1.3f, vendor.Profile.SellPrice);
|
|
|
|
VendorShopItem item = Assert.Single(vendor.Items);
|
|
Assert.Equal(0x50002000u, item.ItemGuid);
|
|
Assert.Equal(1, item.StackSize);
|
|
Assert.Equal("Iron Sword", item.Name);
|
|
Assert.Equal(42u, item.WeenieClassId);
|
|
Assert.Equal((uint)ItemType.Weapon, item.ItemType);
|
|
Assert.Equal(0x1234u | CreateObject.IconTypePrefix, item.IconId);
|
|
// Value is the price-relevant field: it feeds VendorPricing's
|
|
// BuyPrice/SellPrice formula alongside the profile rates above.
|
|
Assert.Equal(250, item.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void SecondApproachVendorEventFromDifferentVendor_ReplacesTheSession()
|
|
{
|
|
using GameRuntime runtime = Create();
|
|
VendorState vendor = runtime.InventoryOwner.Vendor;
|
|
using IDisposable wiring = Wire(vendor);
|
|
var kinds = new List<VendorStateTransitionKind>();
|
|
vendor.Changed += t => kinds.Add(t.Kind);
|
|
|
|
Dispatch(BuildApproachVendorPayload(
|
|
vendorGuid: 0x40001000u,
|
|
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
|
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
|
currencyName: "",
|
|
items:
|
|
[
|
|
new VendorItemFixture(
|
|
0x50002000u, 1, "First Vendor Item", 1u, 1u,
|
|
(uint)ItemType.Misc, 10),
|
|
]));
|
|
|
|
Assert.Equal(0x40001000u, vendor.VendorId);
|
|
Assert.Single(vendor.Items);
|
|
|
|
Dispatch(BuildApproachVendorPayload(
|
|
vendorGuid: 0x40002000u,
|
|
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
|
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
|
currencyName: "",
|
|
items:
|
|
[
|
|
new VendorItemFixture(
|
|
0x50003000u, 1, "Second Vendor Item A", 2u, 2u,
|
|
(uint)ItemType.Misc, 20),
|
|
new VendorItemFixture(
|
|
0x50003001u, 1, "Second Vendor Item B", 3u, 3u,
|
|
(uint)ItemType.Misc, 30),
|
|
]));
|
|
|
|
// Replaced, not merged: the second vendor's own guid and item list
|
|
// only, the first vendor's items are gone.
|
|
Assert.Equal(0x40002000u, vendor.VendorId);
|
|
Assert.Equal(2, vendor.Items.Count);
|
|
Assert.DoesNotContain(
|
|
vendor.Items,
|
|
i => i.ItemGuid == 0x50002000u);
|
|
Assert.Contains(
|
|
vendor.Items,
|
|
i => i.Name == "Second Vendor Item A");
|
|
|
|
// Both opens are a DIFFERENT vendor id, so both are "Opened" — never
|
|
// "Refreshed" (that kind is reserved for a same-guid re-approach,
|
|
// e.g. a Slice 6 post-buy/sell snapshot refresh).
|
|
Assert.Equal(
|
|
[VendorStateTransitionKind.Opened, VendorStateTransitionKind.Opened],
|
|
kinds);
|
|
}
|
|
|
|
[Fact]
|
|
public void MalformedApproachVendorEvent_IsDroppedWithoutStateChange()
|
|
{
|
|
using GameRuntime runtime = Create();
|
|
VendorState vendor = runtime.InventoryOwner.Vendor;
|
|
using IDisposable wiring = Wire(vendor);
|
|
|
|
Dispatch(BuildApproachVendorPayload(
|
|
vendorGuid: 0x40001000u,
|
|
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
|
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
|
currencyName: "",
|
|
items:
|
|
[
|
|
new VendorItemFixture(
|
|
0x50002000u, 1, "Untouched Item", 1u, 1u,
|
|
(uint)ItemType.Misc, 10),
|
|
]));
|
|
Assert.Equal(0x40001000u, vendor.VendorId);
|
|
|
|
// Two bytes cannot even hold the vendor guid's own u32 — TryParse's
|
|
// outer catch returns null and the handler's `if (p is null) return;`
|
|
// drops it silently (matches every sibling handler in
|
|
// GameEventWiring.cs — see the comment on the ApproachVendor
|
|
// registration).
|
|
Dispatch(new byte[] { 1, 2 });
|
|
|
|
Assert.Equal(0x40001000u, vendor.VendorId);
|
|
Assert.Single(vendor.Items);
|
|
Assert.Equal("Untouched Item", vendor.Items[0].Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetGeneration_ClearsTheOpenVendorSession()
|
|
{
|
|
// Covers session reset / portal-out / logout uniformly: research
|
|
// doc §C.2 confirms these all funnel through ONE Runtime mechanism
|
|
// (RuntimeGenerationReset's staged transaction), the same single
|
|
// call site ExternalContainerState's own reset already shares.
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
Assert.True(runtime.InventoryOwner.Vendor.Apply(
|
|
0x40001000u, default, Array.Empty<VendorShopItem>()));
|
|
Assert.Equal(0x40001000u, runtime.InventoryOwner.Vendor.VendorId);
|
|
|
|
runtime.ResetGeneration(new RuntimeGenerationToken(1), new NoOpResetHost());
|
|
|
|
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
|
Assert.Empty(runtime.InventoryOwner.Vendor.Items);
|
|
}
|
|
|
|
[Fact]
|
|
public void DisposingInventoryOwner_ClearsTheOpenVendorSession()
|
|
{
|
|
// The other teardown edge: final GameRuntime disposal (as opposed
|
|
// to an in-place generation reset) also converges Vendor to closed,
|
|
// per RuntimeInventoryState.Dispose's fixed-order child reset.
|
|
using GameRuntime runtime = Create();
|
|
Assert.True(runtime.InventoryOwner.Vendor.Apply(
|
|
0x40001000u, default, Array.Empty<VendorShopItem>()));
|
|
|
|
runtime.InventoryOwner.Dispose();
|
|
|
|
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApproachVendorEvent_MaterializesShopItemsIntoTheOwnedObjectTable()
|
|
{
|
|
// Slice 6.1: unlike Wire()'s throwaway ClientObjectTable (used by
|
|
// the tests above, which only assert on VendorState itself), this
|
|
// wires the dispatcher against the SAME table
|
|
// RuntimeInventoryState.Objects exposes, so the
|
|
// VendorShopItemMaterializer subscription RuntimeInventoryState's
|
|
// constructor installs is exercised end-to-end from the real wire
|
|
// parse through to ClientObjectTable.
|
|
using GameRuntime runtime = Create();
|
|
using IDisposable wiring = GameEventWiring.WireAll(
|
|
_dispatcher,
|
|
runtime.InventoryOwner.Objects,
|
|
new CombatState(),
|
|
new Spellbook(),
|
|
new ChatLog(),
|
|
vendor: runtime.InventoryOwner.Vendor);
|
|
|
|
Dispatch(BuildApproachVendorPayload(
|
|
vendorGuid: 0x40001000u,
|
|
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
|
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
|
currencyName: "",
|
|
items:
|
|
[
|
|
new VendorItemFixture(
|
|
0x50002000u, 1, "Iron Sword", 42u, 0x1234u,
|
|
(uint)ItemType.Weapon, 250),
|
|
]));
|
|
|
|
ClientObject? shopItem = runtime.InventoryOwner.Objects.Get(0x50002000u);
|
|
Assert.NotNull(shopItem);
|
|
Assert.Equal(0x40001000u, shopItem!.ContainerId);
|
|
Assert.Equal("Iron Sword", shopItem.Name);
|
|
Assert.Equal(1, runtime.InventoryOwner.VendorItems.OwnedCount);
|
|
|
|
runtime.InventoryOwner.Vendor.Close();
|
|
|
|
Assert.Null(runtime.InventoryOwner.Objects.Get(0x50002000u));
|
|
Assert.Equal(0, runtime.InventoryOwner.VendorItems.OwnedCount);
|
|
// Close() is a client-local session end, not full disposal, so only
|
|
// the vendor-specific ownership dimensions are asserted here — the
|
|
// full IsConverged gate is exercised by DisposingInventoryOwner_
|
|
// ClearsTheOpenVendorSession below.
|
|
RuntimeInventoryOwnershipSnapshot snapshot = runtime.InventoryOwner.CaptureOwnership();
|
|
Assert.Equal(0u, snapshot.VendorId);
|
|
Assert.Equal(0, snapshot.MaterializedVendorItemCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void SecondUse_AfterLocalXClose_StillDispatchesOverTheWireAndReopensOnReapproach()
|
|
{
|
|
// G4 (vendor gate finding, client-side half): the user reported that
|
|
// after X-closing the vendor panel (VendorState.Close() -- a PURE
|
|
// client-local UI teardown per research doc A.3/A.4, no wire send),
|
|
// using the vendor again does nothing. This isolates the Runtime
|
|
// machinery a repeat Use actually depends on --
|
|
// RuntimeInteractionTransactionState's reservation/busy-count gate
|
|
// and VendorState's own open/close bookkeeping -- with NO App-layer
|
|
// world-picking in the loop (AcDream.Runtime.Tests cannot reference
|
|
// AcDream.App). If this passes, the suppression (if real) is NOT in
|
|
// Runtime; it would have to be in the App-layer picking/identity
|
|
// chain (WorldSelectionQuery/SelectionInteractionController), which
|
|
// needs its own harness to confirm or rule out.
|
|
using GameRuntime runtime = Create();
|
|
VendorState vendor = runtime.InventoryOwner.Vendor;
|
|
using IDisposable wiring = Wire(vendor);
|
|
RuntimeInteractionTransactionState transactions = runtime.ActionOwner.Transactions;
|
|
var transport = new FakeTransport();
|
|
const uint vendorGuid = 0x40001000u;
|
|
|
|
// --- First open: press Use -> dispatch -> ApproachVendor arrives ->
|
|
// UseDone arrives (ACE's Player_Use.TryUseItem ALWAYS schedules
|
|
// SendUseDoneEvent() after ActOnUse returns, since Vendor.ActOnUse
|
|
// never sets LastUseTime = float.MinValue -- confirmed against
|
|
// references/ACE/Source/ACE.Server/WorldObjects/{Vendor,Player_Use}.cs).
|
|
ItemUseRequestReservation reservation1 =
|
|
transactions.BeginUseRequestReservation();
|
|
RuntimeInteractionDispatchResult result1 = transactions.TryDispatchUse(
|
|
vendorGuid,
|
|
ownedByPlayer: false,
|
|
useable: true,
|
|
reservation1,
|
|
transport,
|
|
out _);
|
|
Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result1);
|
|
Assert.Equal(new[] { vendorGuid }, transport.Uses);
|
|
Assert.Equal(1, transactions.Inventory.BusyCount);
|
|
|
|
Dispatch(BuildApproachVendorPayload(
|
|
vendorGuid: vendorGuid,
|
|
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
|
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
|
currencyName: "",
|
|
items: []));
|
|
Assert.Equal(vendorGuid, vendor.VendorId);
|
|
|
|
transactions.CompleteUse(0u);
|
|
Assert.Equal(0, transactions.Inventory.BusyCount);
|
|
|
|
// --- X-close: the ONLY thing our client does locally.
|
|
Assert.True(vendor.Close());
|
|
Assert.Equal(0u, vendor.VendorId);
|
|
|
|
// --- Second Use attempt on the SAME vendor guid, well past the
|
|
// 200ms retail throttle (irrelevant here since TryDispatchUse has no
|
|
// throttle of its own -- that lives one layer up in
|
|
// ItemInteractionController/App -- but asserted for clarity).
|
|
ItemUseRequestReservation reservation2 =
|
|
transactions.BeginUseRequestReservation();
|
|
RuntimeInteractionDispatchResult result2 = transactions.TryDispatchUse(
|
|
vendorGuid,
|
|
ownedByPlayer: false,
|
|
useable: true,
|
|
reservation2,
|
|
transport,
|
|
out _);
|
|
|
|
// If this fails, RuntimeInteractionTransactionState/VendorState is
|
|
// the suppressor. If it passes (expected, given VendorState.Close()
|
|
// touches no interaction-transaction state and ActiveVendorId only
|
|
// gates USING SHOP ITEMS, not the vendor NPC itself -- see
|
|
// ItemInteractionPolicy.DecideUse's ContainerId check), the
|
|
// suppression is NOT here.
|
|
Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result2);
|
|
Assert.Equal(new[] { vendorGuid, vendorGuid }, transport.Uses);
|
|
Assert.Equal(1, transactions.Inventory.BusyCount);
|
|
|
|
// --- Server re-approaches (mirrors Vendor.ActOnUse's UNCONDITIONAL
|
|
// ApproachVendor -- confirmed no server-side "already open" gate
|
|
// exists; see the G4 evidence chain in the final report). Our own
|
|
// VendorState.Apply must reopen from a previous==0 baseline (Close()
|
|
// already zeroed it), which VendorUiController.OnVendorChanged's
|
|
// Opened case turns into _window.Show().
|
|
var kinds = new List<VendorStateTransitionKind>();
|
|
vendor.Changed += t => kinds.Add(t.Kind);
|
|
Dispatch(BuildApproachVendorPayload(
|
|
vendorGuid: vendorGuid,
|
|
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
|
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
|
currencyName: "",
|
|
items: []));
|
|
|
|
Assert.Equal(vendorGuid, vendor.VendorId);
|
|
Assert.Equal([VendorStateTransitionKind.Opened], kinds);
|
|
|
|
transactions.CompleteUse(0u);
|
|
Assert.Equal(0, transactions.Inventory.BusyCount);
|
|
}
|
|
|
|
private sealed class FakeTransport : IRuntimeInteractionTransport
|
|
{
|
|
private uint _sequence;
|
|
public bool IsInWorld { get; set; } = true;
|
|
public List<uint> Uses { get; } = [];
|
|
|
|
public bool TrySendUse(uint serverGuid, out uint sequence)
|
|
{
|
|
if (!IsInWorld)
|
|
{
|
|
sequence = 0u;
|
|
return false;
|
|
}
|
|
sequence = ++_sequence;
|
|
Uses.Add(serverGuid);
|
|
return true;
|
|
}
|
|
|
|
public bool TrySendPickup(
|
|
uint itemGuid,
|
|
uint destinationContainerId,
|
|
int placement,
|
|
out uint sequence)
|
|
{
|
|
sequence = 0u;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VendorId_IsTheLiveActiveVendorIdSeamSource()
|
|
{
|
|
// The App-layer ItemInteractionController wires
|
|
// `activeVendorId: () => runtime.InventoryOwner.Vendor.VendorId` as
|
|
// a LIVE delegate at its one construction site
|
|
// (InteractionRetainedUiComposition.CreateItemInteraction) — so this
|
|
// property IS that seam's data source. Verified here at the Runtime
|
|
// layer since ItemInteractionController itself lives in
|
|
// AcDream.App, which AcDream.Runtime.Tests cannot reference.
|
|
using GameRuntime runtime = Create();
|
|
VendorState vendor = runtime.InventoryOwner.Vendor;
|
|
Assert.Equal(0u, vendor.VendorId);
|
|
|
|
Assert.True(vendor.Apply(
|
|
0x40001000u, default, Array.Empty<VendorShopItem>()));
|
|
Assert.Equal(0x40001000u, vendor.VendorId);
|
|
|
|
Assert.True(vendor.Close());
|
|
Assert.Equal(0u, vendor.VendorId);
|
|
}
|
|
|
|
// ---- fixtures / helpers ------------------------------------------
|
|
|
|
private readonly record struct VendorItemFixture(
|
|
uint ItemGuid,
|
|
int StackSize,
|
|
string Name,
|
|
uint WeenieClassId,
|
|
uint RawIconId,
|
|
uint ItemType,
|
|
int? Value);
|
|
|
|
private IDisposable Wire(VendorState vendor) => GameEventWiring.WireAll(
|
|
_dispatcher,
|
|
new ClientObjectTable(),
|
|
new CombatState(),
|
|
new Spellbook(),
|
|
new ChatLog(),
|
|
vendor: vendor);
|
|
|
|
// One dispatcher per test instance (xUnit constructs a fresh instance
|
|
// per test method, so this is not shared across tests).
|
|
private readonly GameEventDispatcher _dispatcher = new();
|
|
|
|
private void Dispatch(byte[] payload)
|
|
{
|
|
GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
|
|
WrapEnvelope(GameEventType.ApproachVendor, payload));
|
|
Assert.NotNull(envelope);
|
|
_dispatcher.Dispatch(envelope.Value);
|
|
}
|
|
|
|
private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
|
|
{
|
|
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type);
|
|
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
|
|
return body;
|
|
}
|
|
|
|
private static byte[] BuildApproachVendorPayload(
|
|
uint vendorGuid,
|
|
uint categories,
|
|
uint minValue,
|
|
uint maxValue,
|
|
bool dealsMagic,
|
|
float buyPrice,
|
|
float sellPrice,
|
|
uint currencyWcid,
|
|
uint currencyAmount,
|
|
string currencyName,
|
|
IReadOnlyList<VendorItemFixture> items)
|
|
{
|
|
var b = new List<byte>();
|
|
WireU32(b, vendorGuid);
|
|
WireU32(b, categories);
|
|
WireU32(b, minValue);
|
|
WireU32(b, maxValue);
|
|
WireU32(b, dealsMagic ? 1u : 0u);
|
|
WireF32(b, buyPrice);
|
|
WireF32(b, sellPrice);
|
|
WireU32(b, currencyWcid);
|
|
WireU32(b, currencyAmount);
|
|
WireStr16L(b, currencyName);
|
|
WireU32(b, (uint)items.Count);
|
|
foreach (VendorItemFixture item in items)
|
|
{
|
|
uint packed = ((uint)item.StackSize & 0xFFFFFFu) | 0xFF000000u;
|
|
WireU32(b, packed);
|
|
WireU32(b, item.ItemGuid);
|
|
|
|
// Fixed PWD prefix (PublicWeenieDescParser.Parse). Only weenieFlags
|
|
// bit 0x8 (Value) is set here — the single optional-tail field
|
|
// these fixtures need.
|
|
uint weenieFlags = item.Value.HasValue ? 0x00000008u : 0u;
|
|
WireU32(b, weenieFlags);
|
|
WireStr16L(b, item.Name);
|
|
WirePackedDword(b, item.WeenieClassId);
|
|
WirePackedDword(b, item.RawIconId);
|
|
WireU32(b, item.ItemType);
|
|
WireU32(b, 0u); // objectDescriptionFlags
|
|
WireAlign(b);
|
|
if (item.Value.HasValue)
|
|
WireU32(b, unchecked((uint)item.Value.Value));
|
|
// Per-item trailing align — VendorApproach.TryParse's own
|
|
// AlignTo4 call after the shared parser returns.
|
|
WireAlign(b);
|
|
}
|
|
return b.ToArray();
|
|
}
|
|
|
|
private static void WireU32(List<byte> b, uint v)
|
|
{
|
|
Span<byte> t = stackalloc byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(t, v);
|
|
b.AddRange(t.ToArray());
|
|
}
|
|
|
|
private static void WireF32(List<byte> b, float v) =>
|
|
WireU32(b, unchecked((uint)BitConverter.SingleToInt32Bits(v)));
|
|
|
|
private static void WireU16(List<byte> b, ushort v)
|
|
{
|
|
Span<byte> t = stackalloc byte[2];
|
|
BinaryPrimitives.WriteUInt16LittleEndian(t, v);
|
|
b.AddRange(t.ToArray());
|
|
}
|
|
|
|
/// <summary>ACE Extensions.cs:12-21 — u16 length, CP1252 bytes, pad to
|
|
/// a multiple of 4 including the 2 length bytes.</summary>
|
|
private static void WireStr16L(List<byte> b, string s)
|
|
{
|
|
byte[] bytes = Encoding.GetEncoding(1252).GetBytes(s);
|
|
WireU16(b, (ushort)bytes.Length);
|
|
b.AddRange(bytes);
|
|
int total = 2 + bytes.Length;
|
|
int pad = (4 - (total & 3)) & 3;
|
|
for (int i = 0; i < pad; i++) b.Add(0);
|
|
}
|
|
|
|
/// <summary>ACE Extensions.cs:23-34 — values <= 32767 as a plain u16;
|
|
/// larger values as the extended two-u16 form.</summary>
|
|
private static void WirePackedDword(List<byte> b, uint v)
|
|
{
|
|
if (v <= 32767)
|
|
{
|
|
WireU16(b, (ushort)v);
|
|
return;
|
|
}
|
|
uint packed = (v << 16) | ((v >> 16) | 0x8000);
|
|
WireU32(b, packed);
|
|
}
|
|
|
|
private static void WireAlign(List<byte> b)
|
|
{
|
|
int pad = (4 - (b.Count & 3)) & 3;
|
|
for (int i = 0; i < pad; i++) b.Add(0);
|
|
}
|
|
|
|
private static GameRuntime Create()
|
|
{
|
|
var operations = new Operations();
|
|
return new GameRuntime(new GameRuntimeDependencies(
|
|
operations,
|
|
operations,
|
|
operations,
|
|
operations));
|
|
}
|
|
|
|
private sealed class NoOpResetHost : IRuntimeGenerationResetHost
|
|
{
|
|
public void RetireEntityProjection(RuntimeEntityRecord entity) { }
|
|
public void DrainEntityProjectionBoundary() { }
|
|
public void CompleteEntityProjectionRetirement() { }
|
|
}
|
|
|
|
private sealed class Operations :
|
|
IRuntimeCombatAttackOperations,
|
|
IRuntimeCombatTargetOperations,
|
|
IRuntimeCombatModeOperations,
|
|
IRuntimeSpellCastOperations
|
|
{
|
|
public bool CanStartAttack() => false;
|
|
public void PrepareAttackRequest() { }
|
|
public bool SendAttack(AttackHeight height, float power) => false;
|
|
public void SendCancelAttack() { }
|
|
public bool IsDualWield => false;
|
|
public bool PlayerReadyForAttack => false;
|
|
public bool AutoRepeatAttack => false;
|
|
public bool AutoTarget => false;
|
|
public uint? SelectClosestTarget() => null;
|
|
public bool IsInWorld => false;
|
|
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
|
public void NotifyExplicitCombatModeRequest() { }
|
|
public void SendChangeCombatMode(CombatMode mode) { }
|
|
public uint LocalPlayerId => 0u;
|
|
public bool CanSend => false;
|
|
public bool HasRequiredComponents(uint spellId) => false;
|
|
public bool IsTargetCompatible(
|
|
uint targetId,
|
|
SpellMetadata spell,
|
|
bool showMessage) => false;
|
|
public void StopCompletely() { }
|
|
public void SendUntargeted(uint spellId) { }
|
|
public void SendTargeted(uint targetId, uint spellId) { }
|
|
public void DisplayMessage(string message) { }
|
|
public void IncrementBusy() { }
|
|
}
|
|
}
|