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
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:
parent
d003449bb4
commit
02b735ba4a
21 changed files with 1139 additions and 102 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<=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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2442,6 +2442,105 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
return result.ToImmutable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-08 vendor-approach root fix (the armed-but-inert far Use):
|
||||
/// the published local player's host seam <c>getObjectA</c> is retail's
|
||||
/// <c>CObjectMaint::GetObjectA</c> — it must resolve ANY in-world
|
||||
/// object so <c>TargetManager.SetTarget</c>'s <c>add_voyeur</c> can
|
||||
/// deliver the immediate initial target snapshot a deferred
|
||||
/// <c>MoveToObject</c> needs before it queues a single node. The old
|
||||
/// binding went straight to
|
||||
/// <c>RuntimePhysicsState.TryGetPhysicsHost</c> (installed hosts only),
|
||||
/// so a moveto against a never-animated NPC/static target — no
|
||||
/// remote-motion binding, no installed host — armed but never
|
||||
/// initialized: no nodes, no movement, no natural completion, until
|
||||
/// user input or the 10 s staleness timeout cancelled it (the live
|
||||
/// vendor-diag evidence: three consecutive approaches at 7.37/6.01/
|
||||
/// 5.49 m sat inert; the fourth worked only because the vendor had
|
||||
/// animated by then and gained a host).
|
||||
///
|
||||
/// Phase 1 pins the unbound fallback (exact installed hosts only — the
|
||||
/// no-window shape): the moveto arms and stays UNinitialized with an
|
||||
/// empty node plan. Phase 2 binds the canonical object-table resolver
|
||||
/// (the graphical host's <c>ResolvePhysicsHost</c> stand-in, installed
|
||||
/// by <c>SessionPlayerComposition</c> through
|
||||
/// <c>RuntimePhysicsState.BindObjectTableHostResolver</c>) and proves
|
||||
/// the SAME published moveto now receives the AddVoyeur snapshot
|
||||
/// synchronously and builds its node plan. Sabotage-verified: with the
|
||||
/// pre-fix <c>getObjectA: _physics.TryGetPhysicsHost</c> binding,
|
||||
/// Phase 2 fails (the bound resolver is never consulted).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PublishedLocalPlayerMoveToResolvesUninstalledTargetsThroughTheBoundObjectTableResolver()
|
||||
{
|
||||
using var fixture = new Fixture(residentWorld: true);
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
|
||||
fixture.Owner.Commit(
|
||||
fixture.Prepare(),
|
||||
out RuntimeLocalPlayerPhysicsActivationToken token));
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
|
||||
fixture.Owner.EvaluateActivation(token, out var evaluation));
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
|
||||
fixture.Owner.CommitActivation(evaluation, out _));
|
||||
Assert.NotNull(fixture.Record.PhysicsHost);
|
||||
MovementManager movement = fixture.Movement.Controller!.Movement;
|
||||
MoveToManager moveTo = movement.MoveTo!;
|
||||
|
||||
const uint vendorGuid = 0x7C95B01Cu;
|
||||
Vector3 vendorPos =
|
||||
fixture.Record.PhysicsBody!.Position + new Vector3(10f, 0f, 0f);
|
||||
MovementStruct Approach() => new()
|
||||
{
|
||||
ObjectId = vendorGuid,
|
||||
TopLevelId = vendorGuid,
|
||||
Pos = new AcDream.Core.Physics.Position(
|
||||
Cell, vendorPos, Quaternion.Identity),
|
||||
Params = new MovementParameters
|
||||
{
|
||||
DistanceToObject = 3f,
|
||||
CanCharge = true,
|
||||
},
|
||||
Type = MovementType.MoveToObject,
|
||||
Radius = 0.5f,
|
||||
Height = 2f,
|
||||
};
|
||||
|
||||
// Phase 1 — no resolver bound (the no-window fallback): the target
|
||||
// has no installed host, so the deferred object move arms but never
|
||||
// receives its first target update — exactly the pre-fix pathology.
|
||||
Assert.Equal(WeenieError.None, movement.PerformMovement(Approach()));
|
||||
Assert.True(moveTo.IsMovingTo());
|
||||
Assert.False(moveTo.Initialized);
|
||||
Assert.Empty(moveTo.PendingActions);
|
||||
moveTo.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
|
||||
// Phase 2 — the graphical host's bind: the SAME published moveto
|
||||
// resolves the never-animated target through the object-table
|
||||
// resolver, receives AddVoyeur's immediate Ok snapshot
|
||||
// synchronously, and builds its node plan.
|
||||
var vendorHost = new EntityPhysicsHost(
|
||||
vendorGuid,
|
||||
getPosition: () => new AcDream.Core.Physics.Position(
|
||||
Cell, vendorPos, 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 () => { });
|
||||
fixture.Lifetime.Physics.BindObjectTableHostResolver(
|
||||
guid => guid == vendorGuid ? vendorHost : null);
|
||||
|
||||
Assert.Equal(WeenieError.None, movement.PerformMovement(Approach()));
|
||||
Assert.True(moveTo.IsMovingTo());
|
||||
Assert.True(moveTo.Initialized);
|
||||
Assert.NotEmpty(moveTo.PendingActions);
|
||||
moveTo.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
}
|
||||
|
||||
private readonly record struct EvaluationPuritySnapshot(
|
||||
RuntimePhysicsOwnershipSnapshot PhysicsOwnership,
|
||||
RuntimeSetPositionOwnershipSnapshot SetPositionOwnership,
|
||||
|
|
|
|||
|
|
@ -136,6 +136,11 @@ public sealed class VendorShopItemMaterializerTests
|
|||
/// <c>DescStackSize</c>, packed <c>StackSize=-1</c> (unlimited),
|
||||
/// <c>MaxStackSize=1000</c> -> <c>ClientObject.StackSize</c> resolves
|
||||
/// to 1000, not 1 and not the (nonsensical, unbounded) packed field.
|
||||
/// (Second correction, 2026-08-08: the live wire showed ACE actually
|
||||
/// sends <c>descStackSize=1</c>, so <c>MaxStackSize</c> is now the
|
||||
/// PRIMARY operand rather than a fallback — see
|
||||
/// <see cref="Apply_LiveAceWireShape_DescOneMaxHundred_ResolvesToTheAuthoredCeiling"/>;
|
||||
/// this desc-absent case resolves identically either way.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Apply_UnlimitedStockNoDescStackSize_FallsBackToMaxStackSize()
|
||||
|
|
@ -157,6 +162,42 @@ public sealed class VendorShopItemMaterializerTests
|
|||
Assert.Equal(1000, item.StackSizeMax);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-08 live-evidence re-fix (register AP-169, second
|
||||
/// correction): the EXACT wire shape the vendor-diag run captured from
|
||||
/// the live ACE server — `descStackSize=1 stackSizeMax=100` for every
|
||||
/// browse row (e.g. the Smelting Pot / Lead Scarab rows,
|
||||
/// `[vendor-diag] ApproachVendor wire-item[0] ... descStackSize=1
|
||||
/// stackSizeMax=100`). ACE DOES serialize the instance stack size, at
|
||||
/// the useless value 1, so the R1 desc-first preference resolved every
|
||||
/// vendor stack to 1 and the toolbar split slider never appeared
|
||||
/// (`ApplySelection ... failingPredicate=stackSize<=1u`). Retail's
|
||||
/// own vendor UI reads <c>pwd._maxStackSize</c> directly
|
||||
/// (<c>VendorItemsUI::UpdateItemsList</c> <c>0x004c1ea0</c>,
|
||||
/// <c>pc:201085-201133</c>), so the materialized ceiling must be 100
|
||||
/// here. Sabotage-verified: restoring the desc-first preference makes
|
||||
/// this resolve 1 and fail.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Apply_LiveAceWireShape_DescOneMaxHundred_ResolvesToTheAuthoredCeiling()
|
||||
{
|
||||
var vendor = new VendorState();
|
||||
var objects = new ClientObjectTable();
|
||||
using var materializer = new VendorShopItemMaterializer(vendor, objects);
|
||||
|
||||
vendor.Apply(VendorGuid, default, new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
ItemA, StackSize: -1, WeenieClassId: 1u, Name: "Lead Scarab",
|
||||
ItemType: (uint)ItemType.SpellComponents, IconId: 0x1234u, Value: 10,
|
||||
DescStackSize: 1, MaxStackSize: 100),
|
||||
});
|
||||
|
||||
ClientObject item = objects.Get(ItemA)!;
|
||||
Assert.Equal(100, item.StackSize);
|
||||
Assert.Equal(100, item.StackSizeMax);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sabotage-adjacent control: the SAME unlimited-stock listing but with
|
||||
/// <see cref="VendorShopItem.MaxStackSize"/> ALSO absent (neither wire
|
||||
|
|
|
|||
|
|
@ -52,6 +52,77 @@ public sealed class RuntimePhysicsStateTests
|
|||
Assert.True(physics.CaptureOwnership().IsDisposed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-08 vendor-approach root fix: <c>ResolveObjectTableHost</c> is
|
||||
/// the retail <c>CObjectMaint::GetObjectA</c> seam consumed by the local
|
||||
/// player's publication-chain host. Unbound (no-window hosts) it answers
|
||||
/// exact installed hosts only; bound (the graphical host's canonical
|
||||
/// lazy resolver, installed by <c>SessionPlayerComposition</c>) it
|
||||
/// delegates every lookup — including entities that have no installed
|
||||
/// host yet, the case whose null answer left a far-use MoveToObject
|
||||
/// armed but inert. Dispose clears the binding so a retired session
|
||||
/// route cannot leak its App closure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveObjectTableHostDelegatesToTheBoundResolverAndFallsBackToInstalledHosts()
|
||||
{
|
||||
var lifetime = new RuntimeEntityObjectLifetime();
|
||||
RuntimeEntityRecord record =
|
||||
lifetime.Entities.AddActive(Spawn(0x70000001u, 1));
|
||||
EntityPhysicsHost installed = MinimalHost(record.ServerGuid);
|
||||
lifetime.Physics.InstallPhysicsHost(record, installed);
|
||||
|
||||
const uint uninstalledGuid = 0x7C95B01Cu;
|
||||
// Unbound fallback: installed hosts resolve, anything else is null.
|
||||
Assert.Same(
|
||||
installed,
|
||||
lifetime.Physics.ResolveObjectTableHost(record.ServerGuid));
|
||||
Assert.Null(lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
|
||||
|
||||
// Bound: the canonical resolver owns EVERY lookup.
|
||||
EntityPhysicsHost lazyMinimal = MinimalHost(uninstalledGuid);
|
||||
var resolvedGuids = new List<uint>();
|
||||
lifetime.Physics.BindObjectTableHostResolver(guid =>
|
||||
{
|
||||
resolvedGuids.Add(guid);
|
||||
return guid == uninstalledGuid ? lazyMinimal : null;
|
||||
});
|
||||
Assert.Same(
|
||||
lazyMinimal,
|
||||
lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
|
||||
Assert.Null(lifetime.Physics.ResolveObjectTableHost(0x70000002u));
|
||||
Assert.Equal(
|
||||
new[] { uninstalledGuid, 0x70000002u },
|
||||
resolvedGuids);
|
||||
|
||||
// Rebinding replaces (last bind wins); null clears back to the
|
||||
// exact-installed-host fallback.
|
||||
lifetime.Physics.BindObjectTableHostResolver(null);
|
||||
Assert.Null(lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
|
||||
Assert.Same(
|
||||
installed,
|
||||
lifetime.Physics.ResolveObjectTableHost(record.ServerGuid));
|
||||
|
||||
lifetime.Physics.BindObjectTableHostResolver(_ => lazyMinimal);
|
||||
lifetime.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
|
||||
}
|
||||
|
||||
private static EntityPhysicsHost MinimalHost(uint guid) => new(
|
||||
guid,
|
||||
getPosition: static () => new AcDream.Core.Physics.Position(
|
||||
0u, Vector3.Zero, Quaternion.Identity),
|
||||
getVelocity: static () => Vector3.Zero,
|
||||
getRadius: static () => 0f,
|
||||
inContact: static () => true,
|
||||
minterpMaxSpeed: static () => null,
|
||||
curTime: static () => 0d,
|
||||
physicsTimerTime: static () => 0d,
|
||||
getObjectA: static _ => null,
|
||||
handleUpdateTarget: static _ => { },
|
||||
interruptCurrentMovement: static () => { });
|
||||
|
||||
[Fact]
|
||||
public void CanonicalRecordAndPhysicsOwnerOwnRemoteComponentAndWorksets()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue