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

@ -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,

View file

@ -136,6 +136,11 @@ public sealed class VendorShopItemMaterializerTests
/// <c>DescStackSize</c>, packed <c>StackSize=-1</c> (unlimited),
/// <c>MaxStackSize=1000</c> -&gt; <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&lt;=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