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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue