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

@ -193,33 +193,43 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(transport);
sequence = 0u;
RuntimeInteractionDispatchResult verdict;
if (serverGuid == 0u)
{
reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.Rejected;
verdict = RuntimeInteractionDispatchResult.Rejected;
}
if (!transport.IsInWorld)
else if (!transport.IsInWorld)
{
reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.NotInWorld;
verdict = RuntimeInteractionDispatchResult.NotInWorld;
}
if (!ownedByPlayer && !useable)
else if (!ownedByPlayer && !useable)
{
reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.NotUseable;
verdict = RuntimeInteractionDispatchResult.NotUseable;
}
if (!transport.TrySendUse(serverGuid, out sequence))
else if (!transport.TrySendUse(serverGuid, out sequence))
{
reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.Rejected;
verdict = RuntimeInteractionDispatchResult.Rejected;
}
else
{
reservation?.MarkDispatched();
_lastUseSourceId = serverGuid;
_lastUseTargetId = 0u;
IncrementRevision();
verdict = RuntimeInteractionDispatchResult.Dispatched;
}
reservation?.MarkDispatched();
_lastUseSourceId = serverGuid;
_lastUseTargetId = 0u;
IncrementRevision();
return RuntimeInteractionDispatchResult.Dispatched;
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] TryDispatchUse guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable} verdict={verdict} seq={sequence}");
}
return verdict;
}
public bool TryDispatchTargetedUse(
@ -623,6 +633,11 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
if (_pendingUse is not { } current
|| current.ApproachToken != approachToken)
{
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] TryResolveUseApproachCompletion no-match approachToken=({approachToken.ControllerLifetime},{approachToken.ApproachGeneration}) natural={natural} hasPendingUse={_pendingUse is not null}");
}
pending = default;
return false;
}
@ -630,6 +645,11 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
_pendingUse = null;
pending = current;
IncrementRevision();
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] TryResolveUseApproachCompletion guid=0x{pending.ServerGuid:X8} natural={natural} accepted={natural}");
}
return natural;
}

View file

@ -296,9 +296,24 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
minterpMaxSpeed: () => motion.GetAdjustedMaxSpeed(),
curTime: () => controller.SimTimeSeconds,
physicsTimerTime: () => controller.SimTimeSeconds,
getObjectA: id => _physics.TryGetPhysicsHost(id, out var host)
? host
: null,
// 2026-08-08 vendor-approach root cause: this seam is retail's
// CObjectMaint::GetObjectA — it must resolve ANY in-world object
// so TargetManager.SetTarget's add_voyeur can deliver the
// immediate initial snapshot that a deferred MoveToObject/
// TurnToObject needs before it queues a single node (UseTime's
// object-move gate stays closed until that first
// HandleUpdateTarget). The previous binding went straight to
// _physics.TryGetPhysicsHost, which answers only entities whose
// host is ALREADY installed (remote-motion-bound movers) — a
// never-animated NPC/static target resolved to null, add_voyeur
// never ran, and the armed approach sat inert (no nodes, no
// movement, no completion) until user input or the 10 s
// staleness timeout cancelled it. ResolveObjectTableHost routes
// through the host-bound canonical resolver (the SAME
// lazy-minimal-host lookup every remote host's GetObjectA uses)
// and falls back to the exact installed-host lookup when no
// resolver is bound (no-window hosts).
getObjectA: _physics.ResolveObjectTableHost,
// C3c: the [autowalk-target]/[autowalk-end] probes moved here
// with controller construction (previously App-side in
// PlayerModeController.BuildControllerAndCamera); they stay on

View file

@ -191,6 +191,7 @@ public sealed class VendorShopItemMaterializer : IDisposable
}
}
int diagIndex = 0;
foreach (VendorShopItem item in currentItems)
{
bool ownedAlready = _ownedGuids.ContainsKey(item.ItemGuid);
@ -206,7 +207,19 @@ public sealed class VendorShopItemMaterializer : IDisposable
continue;
}
_objects.Ingest(ToWeenieData(item, transition.VendorId));
WeenieData materialized = ToWeenieData(item, transition.VendorId);
if (VendorDiagnostics.DumpVendorEnabled && diagIndex < 5)
{
Console.WriteLine(
$"[vendor-diag] materialize[{diagIndex}] guid=0x{item.ItemGuid:X8} "
+ $"descStackSize={(item.DescStackSize is { } ds ? ds.ToString() : "null")} "
+ $"maxStackSize={(item.MaxStackSize is { } ms ? ms.ToString() : "null")} "
+ $"resolvedStackSize={(materialized.StackSize is { } rs ? rs.ToString() : "null")} "
+ $"resolvedStackSizeMax={(materialized.StackSizeMax is { } rm ? rm.ToString() : "null")}");
diagIndex++;
}
_objects.Ingest(materialized);
nextOwned[item.ItemGuid] = transition.VendorId;
}
}
@ -249,25 +262,30 @@ public sealed class VendorShopItemMaterializer : IDisposable
/// appeared for ANY vendor stack.
/// </para>
/// <para>
/// <b>The G2 packed-supply-count fallback (retired 2026-08-08).</b> An
/// earlier fix fell back to <see cref="VendorShopItem.StackSize"/> (the
/// packed ItemProfile "how many for sale" dword) when
/// <b>The G2 packed-supply-count fallback (retired 2026-08-08) and the
/// R1 desc-first preference (retired the same day, live evidence).</b>
/// An earlier fix fell back to <see cref="VendorShopItem.StackSize"/>
/// (the packed ItemProfile "how many for sale" dword) when
/// <c>DescStackSize</c> was absent. That did not survive live testing —
/// a standard vendor listing (e.g. a Prismatic Taper) has UNLIMITED
/// stock (<c>StackSize == -1</c>), so the fallback produced nothing
/// usable and the bar stayed hidden, matching the live report exactly
/// (bare "Prismatic Taper", no bar, no count). The live retail
/// screenshot showed "1000 Prismatic Tapers" with the bar visible and a
/// ceiling of 1000 — 1000 being the taper's authored MAX STACK SIZE, not
/// any bounded supply count. This now prefers <c>DescStackSize</c> when
/// present (retail-faithful first, forward-compatible with any server
/// that DOES populate it), falling back to
/// <see cref="VendorShopItem.MaxStackSize"/> — the item TYPE's authored
/// stack ceiling, which ACE DOES populate (an ordinary weenie property)
/// and which a real retail server evidently uses AS <c>_stackSize</c>
/// for an unlimited-supply "one full stack" browse listing. See
/// <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> and the
/// register, AP-169.
/// usable and the bar stayed hidden. The R1 replacement preferred
/// <c>DescStackSize</c> on the theory that ACE never populates it for a
/// browse row; the 2026-08-08 vendor-diag live run refuted THAT too —
/// ACE serializes <c>descStackSize=1</c> for every browse row, so
/// desc-first resolved every vendor stack to 1 and the bar stayed
/// hidden again. The operand retail's own vendor UI reads is
/// <c>PublicWeenieDesc::_maxStackSize</c> directly
/// (<c>VendorItemsUI::UpdateItemsList</c> <c>0x004c1ea0</c>,
/// <c>pc:201085-201133</c>: each row displays
/// <c>min(remaining, _maxStackSize)</c>, plain <c>_maxStackSize</c> for
/// unlimited supply; <c>gmVendorUI::InqListSlotCount</c>
/// <c>pc:200052</c> and the Buy cases <c>pc:203996</c>/<c>204086</c>
/// gate on the same field), matching the live retail screenshot
/// ("1000 Prismatic Tapers", ceiling 1000 = the authored max stack
/// size). <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> is
/// therefore max-first with <c>DescStackSize</c> as the fallback. See
/// the register, AP-169.
/// </para>
/// <para>
/// Every other field <see cref="VendorShopItem"/> doesn't carry

View file

@ -1240,6 +1240,57 @@ public sealed class RuntimePhysicsState : IDisposable
return false;
}
/// <summary>
/// The host-installed retail <c>CObjectMaint::GetObjectA</c> stand-in
/// consumed by <see cref="ResolveObjectTableHost"/>. Retail resolves ANY
/// in-world object here — every <c>CPhysicsObj</c> can answer
/// <c>add_voyeur</c>, so a moveto/sticky can target a never-animated
/// static object and still receive the immediate initial target snapshot
/// (<c>TargetManager::AddVoyeur</c> → <c>SendVoyeurUpdate(Ok)</c>). This
/// state's own <see cref="TryGetPhysicsHost"/> is narrower: it answers
/// only entities whose incarnation-stable host is ALREADY installed
/// (remote-motion-bound movers, prior resolve targets), so a moveto
/// bound to it alone arms but never initializes against a host-less
/// target — the 2026-08-08 vendor-approach root cause. The graphical
/// host binds its canonical lazy-resolving object-table lookup
/// (<c>LiveEntityMotionRuntimeController.ResolvePhysicsHost</c>, the
/// SAME resolver every remote host's <c>GetObjectA</c> already uses,
/// including the TS-49 Hidden withholding), which installs a minimal
/// position-only host into this state's canonical ownership on first
/// resolve. Host-scoped, not session-scoped: the binding survives
/// session resets (the bound resolver reads its own live session state
/// per call); last bind wins so a host can rebind across routes.
/// A no-window host that never binds keeps the exact-installed-host
/// behavior via the fallback below.
/// </summary>
private Func<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost?>?
_objectTableHostResolver;
/// <summary>Binds (or replaces) the canonical object-table host
/// resolver — see <see cref="ResolveObjectTableHost"/>. Pass null to
/// clear back to the exact-installed-host fallback.</summary>
public void BindObjectTableHostResolver(
Func<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost?>? resolver)
{
EnsureNotDisposed();
_objectTableHostResolver = resolver;
}
/// <summary>
/// Retail <c>CObjectMaint::GetObjectA(id)</c> for consumers composed
/// inside Runtime (the local player's publication-chain host): the bound
/// canonical resolver when the host installed one, else the exact
/// installed-host lookup (<see cref="TryGetPhysicsHost"/>).
/// </summary>
public AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolveObjectTableHost(
uint serverGuid)
{
EnsureNotDisposed();
if (_objectTableHostResolver is { } resolver)
return resolver(serverGuid);
return TryGetPhysicsHost(serverGuid, out var host) ? host : null;
}
public bool ClearRemoteMotion(RuntimeEntityRecord record)
{
EnsureNotDisposed();
@ -2120,6 +2171,7 @@ public sealed class RuntimePhysicsState : IDisposable
_localPlayerCreateObserved = false;
CellCommitted = null;
_collisionGenerationCommittedObservers.Clear();
_objectTableHostResolver = null;
_disposed = true;
}