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>
428 lines
19 KiB
C#
428 lines
19 KiB
C#
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);
|
||
}
|
||
}
|