fix(vendor): evidence-based pass — max-first stack ceiling; the local player resolves never-animated MoveTo targets
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

Both chains pinned by the live [vendor-diag] run (vendor-diag.log)
after three code-reading rounds each failed:

The split bar: ACE serializes descStackSize=1 for EVERY browse row
(live wire, log 343-348) — the R1-era "ACE never populates desc"
claim is retracted with the line quoted. Retail's vendor sites read
pwd._maxStackSize directly (four sites, incl. UpdateItemsList
@0x004c1ea0 stamping min(remaining, _maxStackSize));
ResolveAuthoredStackSize flips to max-first for its vendor-only
consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt.
Pricing still reads the desc (per-1 values on ACE).

Walk-to-use: the local player's getObjectA seam was bound to
TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a
never-animated vendor has none, so TargetManager.SetTarget got null,
the MoveToObject armed with zero nodes, and UseTime never dispatched.
The log's natural=False completions were the user's own movement keys
(retail-correct input-edge cancels); attempt 4 worked because the
greeting animation had installed a host. RuntimePhysicsState gains
the retail CObjectMaint::GetObjectA seam (bound canonical resolver
with installed-host fallback); the graphical host binds the SAME
lazy-minimal-host resolver every remote already uses — whose own doc
comment names this exact never-animated hazard. The reservation
release was already correct (2b premise refuted with evidence); the
production-wiring invariants are now pinned by four new tests
including the pre-fix pathology as a permanent sabotage control.

AP-169 rewritten a second time, honestly. The [vendor-diag] probe
family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage.

Clean-room complete solution: 11,536 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 17:17:04 +02:00
parent d003449bb4
commit 02b735ba4a
21 changed files with 1139 additions and 102 deletions

View file

@ -0,0 +1,428 @@
using System.Numerics;
using AcDream.App.Interaction;
using AcDream.App.UI;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Selection;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
namespace AcDream.App.Tests.Interaction;
/// <summary>
/// 2026-08-08 vendor-approach root fix, full PRODUCTION wiring (the live
/// vendor-diag evidence: an out-of-range vendor Use armed its approach and
/// then sat inert — the player never walked, the hourglass lingered, and
/// every attempt resolved <c>natural=False</c> only when manual input
/// cancelled it). Unlike <c>SelectionInteractionControllerTests</c> (fake
/// movement sink, the dead <c>OnNaturalMoveToComplete</c> entry point),
/// these tests drive the REAL chain end to end:
/// <c>SelectionInteractionController.RequestUse</c> → the REAL
/// <c>PlayerInteractionMovementSink</c> → the REAL
/// <c>PlayerMovementController</c>/<c>MovementManager</c>/<c>MoveToManager</c>
/// → the REAL <c>EntityPhysicsHost</c>/<c>TargetManager</c> voyeur
/// round-trip resolved through the REAL
/// <c>RuntimePhysicsState.ResolveObjectTableHost</c> seam → the REAL
/// <c>PlayerApproachCompletionState</c> mailbox →
/// <c>SelectionInteractionController.DrainOutbound</c>.
///
/// The root cause lived in the host's <c>getObjectA</c> seam: bound to the
/// exact-installed-host lookup, a never-animated NPC target resolved to
/// null, <c>TargetManager.SetTarget</c>'s <c>add_voyeur</c> never delivered
/// the immediate initial snapshot, and the deferred <c>MoveToObject</c>
/// never queued a node. The fix routes the seam through
/// <c>RuntimePhysicsState.ResolveObjectTableHost</c> with the graphical
/// host's canonical resolver bound
/// (<c>SessionPlayerComposition</c> → <c>BindObjectTableHostResolver</c>).
/// </summary>
public sealed class ProductionUseApproachWiringTests
{
private const uint Player = 0x5000_0001u;
private const uint Vendor = 0x7C95_B01Cu;
private const uint OtherVendor = 0x7C95_B01Du;
private const uint Cell = 0x0101_0001u;
private sealed class Query : IWorldSelectionQuery
{
public Dictionary<uint, InteractionApproach> Approaches { get; } = new();
public uint? PickAtCursor(bool includeSelf) => null;
public uint? PickAt(float mouseX, float mouseY, bool includeSelf) => null;
public void BeginLightingPulse(uint serverGuid) { }
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
{
localEntityId = 101u;
return true;
}
public bool IsCurrent(uint serverGuid, uint localEntityId) => true;
public string Describe(uint serverGuid) => $"Target {serverGuid:X8}";
public bool IsCreature(uint serverGuid) => true;
public bool IsHostileMonster(uint serverGuid) => false;
public bool IsAttackableTarget(uint serverGuid) => false;
public ClosestCombatTarget? FindClosestHostileMonster() => null;
public bool IsUseable(uint serverGuid) => true;
public bool IsPickupable(uint serverGuid) => false;
public bool IsWieldedByPlayer(uint serverGuid) => false;
public bool IsWieldedPositionState(uint serverGuid) => false;
public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null;
public bool TryGetApproach(uint serverGuid, out InteractionApproach approach)
=> Approaches.TryGetValue(serverGuid, out approach);
}
private sealed class Transport : IRuntimeInteractionTransport
{
private uint _sequence;
public bool IsInWorld => true;
public List<uint> Uses { get; } = new();
public bool TrySendUse(uint serverGuid, out uint sequence)
{
sequence = ++_sequence;
Uses.Add(serverGuid);
return true;
}
public bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence)
{
sequence = ++_sequence;
return true;
}
}
private sealed class Harness : IDisposable
{
public readonly RuntimeEntityObjectLifetime RuntimeLifetime = new();
public readonly Query Query = new();
public readonly Transport Transport = new();
public readonly PlayerApproachCompletionState Completions = new();
public readonly IPlayerApproachCompletionSink CompletionLifetime;
public readonly SelectionState Selection = new();
public readonly ClientObjectTable Objects = new();
public readonly InventoryTransactionState Inventory;
public readonly RuntimeInteractionTransactionState Transactions;
public readonly ItemInteractionController Items;
public readonly SelectionInteractionController Controller;
public readonly PlayerMovementController MovementController;
public readonly MoveToManager MoveTo;
public readonly Dictionary<uint, EntityPhysicsHost> TargetHosts = new();
public Harness(bool bindObjectTableResolver)
{
CompletionLifetime = Completions.BeginControllerLifetime();
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Type = ItemType.Creature,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Vendor,
Name = "Archmage",
Type = ItemType.Creature,
Useability = ItemUseability.Remote,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = OtherVendor,
Name = "Other Archmage",
Type = ItemType.Creature,
Useability = ItemUseability.Remote,
});
// ── The real local movement graph, wired the same way
// RuntimeLocalPlayerPhysicsPublicationState.Prepare wires the
// production one: MoveToManager seams onto the controller's
// body, target seams onto the player's own EntityPhysicsHost,
// and the host's getObjectA onto the REAL RuntimePhysicsState
// object-table seam under test. ─────────────────────────────
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SeedPlacementForTest(Vector3.Zero, Cell, Vector3.Zero);
MovementController = controller;
EntityPhysicsHost playerHost = null!;
MoveTo = new MoveToManager(
controller.Motion,
stopCompletely: () =>
_ = controller.StopCompletelyAtPhysicsObjectBoundary(),
getPosition: () => controller.CellPosition,
getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
setHeading: (heading, _) =>
controller.Yaw = MoveToMath.YawFromHeading(heading),
getOwnRadius: static () => 0.48f,
getOwnHeight: static () => 1.835f,
contact: static () => true,
isInterpolating: static () => false,
getVelocity: static () => Vector3.Zero,
getSelfId: static () => Player,
setTarget: (context, target, radius, quantum) =>
playerHost.SetTarget(context, target, radius, quantum),
clearTarget: () => playerHost.ClearTarget(),
getTargetQuantum: () =>
playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: quantum =>
playerHost.TargetManager.SetTargetQuantum(quantum));
playerHost = new EntityPhysicsHost(
Player,
getPosition: () => controller.CellPosition,
getVelocity: static () => Vector3.Zero,
getRadius: static () => 0.48f,
inContact: static () => true,
minterpMaxSpeed: static () => null,
curTime: static () => 0d,
physicsTimerTime: static () => 0d,
// THE seam under test — the production publication binding.
getObjectA: RuntimeLifetime.Physics.ResolveObjectTableHost,
handleUpdateTarget: info =>
controller.Movement.HandleUpdateTarget(info),
interruptCurrentMovement: () =>
controller.Movement.CancelMoveTo(
WeenieError.ActionCancelled));
MoveTo.StickTo = (target, radius, height) =>
playerHost.PositionManager.StickTo(target, radius, height);
MoveTo.Unstick = playerHost.PositionManager.UnStick;
controller.MoveTo = MoveTo;
// PlayerModeController.BuildControllerAndCamera's exact
// completion bindings (the production mailbox publishers).
MoveTo.MoveToComplete = error =>
{
if (error == WeenieError.None)
CompletionLifetime.PublishNaturalCompletion();
else
CompletionLifetime.PublishCancellation(error);
};
MoveTo.MoveToCancelled = error =>
CompletionLifetime.PublishCancellation(error);
if (bindObjectTableResolver)
{
// SessionPlayerComposition's bind, with the lazy App
// resolver stood in by a per-guid minimal-host table (the
// shape LiveEntityMotionRuntimeController.ResolvePhysicsHost
// produces for a never-animated entity).
RuntimeLifetime.Physics.BindObjectTableHostResolver(
guid => TargetHosts.GetValueOrDefault(guid));
}
Inventory = new InventoryTransactionState(Objects);
Transactions = new RuntimeInteractionTransactionState(Inventory);
SelectionInteractionController? selectionController = null;
Items = new ItemInteractionController(
Objects,
Transactions,
new InteractionState(),
() => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: _ => { },
groundObjectId: () => 0u,
placeInBackpack: (item, container, placement) =>
selectionController!.SendPickup(item, container, placement),
requestUse: (guid, reservation) =>
selectionController!.RequestUse(guid, reservation));
Controller = selectionController = new SelectionInteractionController(
Selection,
Query,
Items,
Transport,
new PlayerInteractionMovementSink(
() => MovementController,
Completions),
toast: null,
Completions);
}
/// <summary>Registers a never-animated target: an approach the
/// selection query reports as genuinely out of range (a real walk)
/// plus the minimal position-only host the canonical object-table
/// resolver would lazily create for it.</summary>
public void AddFarTarget(uint serverGuid, Vector3 position)
{
var entity = new WorldEntity
{
Id = 101u,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = [],
};
Query.Approaches[serverGuid] = new InteractionApproach(
new WorldInteractionTarget(serverGuid, 101u, entity),
new PlayerInteractionPose(Cell, Vector3.Zero),
UseRadius: 3f,
IsCloseRange: false,
CanCharge: true,
TargetRadius: 0.5f,
TargetHeight: 2f);
TargetHosts[serverGuid] = new EntityPhysicsHost(
serverGuid,
getPosition: () => new Position(
Cell, position, Quaternion.Identity),
getVelocity: static () => Vector3.Zero,
getRadius: static () => 0.5f,
inContact: static () => true,
minterpMaxSpeed: static () => null,
curTime: static () => 0d,
physicsTimerTime: static () => 0d,
getObjectA: static _ => null,
handleUpdateTarget: static _ => { },
interruptCurrentMovement: static () => { });
}
public void Dispose() => RuntimeLifetime.Dispose();
}
/// <summary>
/// Far use → approach arms → natural arrival → dispatch, through the
/// complete production chain. The target sits inside the approach's
/// arrival band (cylinder distance 3.5 0.48 0.5 = 2.52 m ≤ the 3 m
/// wire use radius), so retail's deferred MoveToObject completes
/// naturally the moment the first voyeur snapshot arrives — which it
/// can ONLY do when the never-animated target resolves through the
/// bound object-table resolver. Sabotage-verified by
/// <see cref="UnresolvableTargetLeavesTheApproachInertUntilCancelReleasesTheReservation"/>:
/// the identical drive without the bind dispatches nothing.
/// </summary>
[Fact]
public void FarUseDispatchesOnNaturalArrivalThroughTheRealMovementChain()
{
using var h = new Harness(bindObjectTableResolver: true);
h.AddFarTarget(Vendor, new Vector3(3.5f, 0f, 0f));
ItemUseRequestReservation reservation =
h.Transactions.BeginUseRequestReservation();
Assert.Equal(1, h.Inventory.BusyCount);
h.Controller.RequestUse(Vendor, reservation);
// The deferred object move initialized off the immediate voyeur
// snapshot and completed naturally into the mailbox; nothing has
// dispatched until the production drain runs.
Assert.Empty(h.Transport.Uses);
h.Controller.DrainOutbound();
Assert.Equal(new[] { Vendor }, h.Transport.Uses);
// Dispatch transfers the busy reference to the authoritative
// UseDone (retail's hourglass-until-UseDone), it does not leak.
Assert.Equal(1, h.Inventory.BusyCount);
h.Transactions.CompleteUse(0u);
Assert.Equal(0, h.Inventory.BusyCount);
}
/// <summary>
/// A genuinely cancelled approach (the retail user-input chain's
/// terminal <c>CancelMoveTo</c>) releases the armed use's reservation in
/// the SAME drain that observes the cancellation — the hourglass clears
/// the moment the approach fails, and nothing reaches the wire.
/// </summary>
[Fact]
public void CancelledApproachReleasesTheArmedReservationInTheSameDrain()
{
using var h = new Harness(bindObjectTableResolver: true);
h.AddFarTarget(Vendor, new Vector3(10f, 0f, 0f));
ItemUseRequestReservation reservation =
h.Transactions.BeginUseRequestReservation();
h.Controller.RequestUse(Vendor, reservation);
// Genuinely walking: the far target initialized a real node plan.
Assert.True(h.MoveTo.IsMovingTo());
Assert.True(h.MoveTo.Initialized);
Assert.NotEmpty(h.MoveTo.PendingActions);
Assert.Equal(1, h.Inventory.BusyCount);
// Retail's input-edge cancel chain terminates here
// (InterruptCurrentMovement → MovementManager.CancelMoveTo).
h.MovementController.Movement.CancelMoveTo(
WeenieError.ActionCancelled);
Assert.Equal(1, h.Inventory.BusyCount);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Equal(0, h.Inventory.BusyCount);
Assert.True(h.Transactions.TryGetPendingUse(out _) == false);
}
/// <summary>
/// A second far use supersedes the first cleanly: the first armed use's
/// reservation releases at the supersede boundary
/// (<c>CancelPendingApproach</c>), the first approach's cancellation
/// no-matches the second's token in the drain, and only the second
/// dispatches on its own natural arrival.
/// </summary>
[Fact]
public void NewFarUseSupersedesThePriorApproachAndReleasesItsReservation()
{
using var h = new Harness(bindObjectTableResolver: true);
h.AddFarTarget(Vendor, new Vector3(10f, 0f, 0f));
h.AddFarTarget(OtherVendor, new Vector3(3.5f, 0f, 0f));
ItemUseRequestReservation first =
h.Transactions.BeginUseRequestReservation();
h.Controller.RequestUse(Vendor, first);
Assert.Equal(1, h.Inventory.BusyCount);
ItemUseRequestReservation second =
h.Transactions.BeginUseRequestReservation();
Assert.Equal(2, h.Inventory.BusyCount);
h.Controller.RequestUse(OtherVendor, second);
// The first reservation released at the supersede boundary; the
// second is armed (near target: its natural completion is already
// queued behind the first approach's cancellation).
Assert.Equal(1, h.Inventory.BusyCount);
h.Controller.DrainOutbound();
Assert.Equal(new[] { OtherVendor }, h.Transport.Uses);
Assert.Equal(1, h.Inventory.BusyCount);
h.Transactions.CompleteUse(0u);
Assert.Equal(0, h.Inventory.BusyCount);
}
/// <summary>
/// The sabotage control for
/// <see cref="FarUseDispatchesOnNaturalArrivalThroughTheRealMovementChain"/>
/// and the permanent pin of the PRE-FIX pathology: without the
/// object-table bind the host's seam answers null for the
/// never-animated target, so the armed MoveToObject never receives its
/// first target update — no nodes, no natural completion, no dispatch,
/// and the reservation (the hourglass) stays held until something
/// cancels the approach; the cancel then still releases it.
/// </summary>
[Fact]
public void UnresolvableTargetLeavesTheApproachInertUntilCancelReleasesTheReservation()
{
using var h = new Harness(bindObjectTableResolver: false);
h.AddFarTarget(Vendor, new Vector3(3.5f, 0f, 0f));
ItemUseRequestReservation reservation =
h.Transactions.BeginUseRequestReservation();
h.Controller.RequestUse(Vendor, reservation);
h.Controller.DrainOutbound();
// Armed but inert — the live vendor-diag pathology.
Assert.True(h.MoveTo.IsMovingTo());
Assert.False(h.MoveTo.Initialized);
Assert.Empty(h.MoveTo.PendingActions);
Assert.Empty(h.Transport.Uses);
Assert.Equal(1, h.Inventory.BusyCount);
h.MovementController.Movement.CancelMoveTo(
WeenieError.ActionCancelled);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Equal(0, h.Inventory.BusyCount);
}
}

View file

@ -696,8 +696,12 @@ public class SelectedObjectControllerTests
/// operand is the item's authored <see cref="VendorShopItem.MaxStackSize"/>
/// (1000 for a Prismatic Taper), not any supply count. This fixture now
/// matches that exact ACE-realistic shape: unlimited packed supply, no
/// <c>DescStackSize</c> (ACE never sets it — see the class doc above),
/// <c>MaxStackSize=1000</c> from the wire.
/// <c>DescStackSize</c>, <c>MaxStackSize=1000</c> from the wire.
/// (Second correction, 2026-08-08: the live vendor-diag wire showed ACE
/// actually serializes <c>descStackSize=1</c> for every browse row, so
/// the resolver is now max-FIRST — see
/// <see cref="LiveAceWireShape_DescOneMaxHundred_ShowsSplitSliderWithTheAuthoredCeiling"/>;
/// this desc-absent fixture resolves identically either way.)
/// </para>
/// </summary>
[Fact]
@ -744,23 +748,20 @@ public class SelectedObjectControllerTests
&& vendorCandidate.ContainerId == vendor.VendorId
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
// G2 root cause, R1-corrected: a REAL ACE vendor listing,
// byte-for-byte. ACE's Vendor.LoadInventoryItem (Vendor.cs:144-172)
// builds the browse-list WorldObject via
// WorldObjectFactory.CreateNewWorldObject and sets ONLY
// wo.VendorShopCreateListStackSize = stackSize ?? -1 (the "how many
// available" packed dword — our VendorShopItem.StackSize) — it
// NEVER calls wo.SetStackSize(...), so the per-item
// PublicWeenieDesc's own conditional StackSize field (our
// DescStackSize — retail's pwd._stackSize, what
// GameEventApproachVendor.cs:60's SerializeGameDataOnly walks) comes
// back null on the real wire. A STANDARD listing (unlimited stock,
// G2 root cause, R1-corrected, second-corrected 2026-08-08: a REAL
// ACE vendor listing. The R1 reading of Vendor.LoadInventoryItem
// concluded DescStackSize "comes back null on the real wire"; the
// live vendor-diag capture showed the real wire actually carries
// descStackSize=1 for every browse row — either way the field never
// names a usable ceiling. A STANDARD listing (unlimited stock,
// packed StackSize=-1) is the common case the live re-test actually
// hit — matching the retail screenshot's Prismatic Taper, not a
// bounded-supply item like the original fixture's "100 arrows".
// MaxStackSize=1000 IS reliably populated by ACE (an ordinary
// weenie property), which is the field the toolbar ceiling now
// resolves through — see VendorSplitPolicy.ResolveAuthoredStackSize.
// weenie property), and it is retail's own vendor-side operand
// (VendorItemsUI::UpdateItemsList 0x004c1ea0 stamps each row's
// displayed stack from pwd._maxStackSize), which the resolver now
// prefers — see VendorSplitPolicy.ResolveAuthoredStackSize.
vendor.Apply(
vendorGuid,
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
@ -788,6 +789,95 @@ public class SelectedObjectControllerTests
controller.Dispose();
}
/// <summary>
/// 2026-08-08 live-evidence re-fix (register AP-169, second
/// correction): the EXACT live ACE wire shape the vendor-diag run
/// captured — every browse row carries <c>descStackSize=1</c> AND
/// <c>stackSizeMax</c> (e.g. a Lead Scarab: desc 1, max 100). The R1
/// desc-first resolver read the 1 and the split bar never appeared
/// (`ApplySelection ... sliderVisible=false
/// failingPredicate=stackSize&lt;=1u stackSize=1` in the live log).
/// Retail's vendor UI reads <c>pwd._maxStackSize</c> directly
/// (<c>VendorItemsUI::UpdateItemsList</c> <c>0x004c1ea0</c>), so the
/// selection must show the bar with ceiling 100, seeded at 1 (scarabs
/// are SpellComponents — split-exempt), and the retail "{count}
/// {plural name}" label. Same REAL-materializer wiring as the G2 test
/// above. Sabotage-verified: restoring the desc-first preference
/// resolves the operand to 1 and every assertion below fails.
/// </summary>
[Fact]
public void LiveAceWireShape_DescOneMaxHundred_ShowsSplitSliderWithTheAuthoredCeiling()
{
const uint vendorGuid = 0x70000012u;
const uint scarabGuid = 0x60009012u;
ImportedLayout layout = FixtureLoader.LoadToolbar();
var objects = new ClientObjectTable();
var vendor = new VendorState();
var selection = new SelectionState();
var splitQuantity = new StackSplitQuantityState();
using var materializer = new VendorShopItemMaterializer(vendor, objects);
SelectedObjectController controller = SelectedObjectController.Bind(
layout,
selection,
subscribeHealthChanged: _ => { },
unsubscribeHealthChanged: _ => { },
subscribeItemManaChanged: _ => { },
unsubscribeItemManaChanged: _ => { },
isHealthTarget: _ => false,
isOwnedByPlayer: _ => false,
name: guid => objects.Get(guid)?.GetAppropriateName(),
healthPercent: _ => 0f,
hasHealth: _ => false,
stackSize: guid => (uint)(objects.Get(guid)?.StackSize ?? 0),
sendQueryHealth: _ => { },
manaPercent: _ => 0f,
sendQueryItemMana: _ => { },
datFont: null,
splitQuantity: splitQuantity,
subscribeObjectUpdated: h => objects.ObjectUpdated += h,
unsubscribeObjectUpdated: h => objects.ObjectUpdated -= h,
isVendorSplitExempt: guid =>
vendor.VendorId != 0u
&& objects.Get(guid) is { } vendorCandidate
&& vendorCandidate.ContainerId == vendor.VendorId
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
vendor.Apply(
vendorGuid,
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
new[]
{
new VendorShopItem(
scarabGuid, StackSize: -1, WeenieClassId: 5u, Name: "Lead Scarab",
ItemType: (uint)ItemType.SpellComponents, IconId: 200u, Value: 10,
DescStackSize: 1, MaxStackSize: 100, PluralName: "Lead Scarabs"),
});
Assert.Equal(100, objects.Get(scarabGuid)!.StackSize);
selection.Select(scarabGuid, SelectionChangeSource.Vendor);
var slider = Assert.IsType<UiScrollbar>(
layout.FindElement(SelectedObjectController.StackSizeSliderId));
Assert.True(slider.Visible);
Assert.Equal(100u, splitQuantity.Maximum);
// Split-exempt (SpellComponents intersects the 0xDC41CB0 mask):
// seeds at 1, ceiling stays the authored stack.
Assert.Equal(1u, splitQuantity.GetObjectSplitSize(
scarabGuid, scarabGuid, 100u));
// Retail's "{count} {plural}" toolbar label.
var nameElement = layout.FindElement(SelectedObjectController.NameId);
Assert.NotNull(nameElement);
UiText nameLabel = Assert.Single(nameElement!.Children.OfType<UiText>());
string renderedName = string.Concat(
nameLabel.LinesProvider().Select(static line => line.Text));
Assert.Equal("100 Lead Scarabs", renderedName);
controller.Dispose();
}
[Fact]
public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation()
{