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
|
|
@ -509,6 +509,20 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.PlayerIdentity,
|
||||
dormantLiveEntities,
|
||||
d.Options.DumpLiveSpawns ? d.Log : null);
|
||||
// 2026-08-08 vendor-approach root cause: the local player's
|
||||
// publication-chain host resolves moveto/sticky targets through
|
||||
// RuntimePhysicsState.ResolveObjectTableHost. Bind the graphical
|
||||
// host's canonical CObjectMaint::GetObjectA stand-in — the SAME
|
||||
// lazy-minimal-host resolver every remote host's GetObjectA uses
|
||||
// (LiveEntityMotionRuntimeController.ResolvePhysicsHost, reached
|
||||
// through the deferred motion-bindings seam so this closure stays
|
||||
// valid across session routes). Without it the local player's
|
||||
// MoveToObject against a never-animated NPC/static object never
|
||||
// received TargetManager.AddVoyeur's immediate initial snapshot and
|
||||
// sat armed-but-inert forever (no nodes, no movement, no natural
|
||||
// completion).
|
||||
live.LiveEntities.Physics.BindObjectTableHostResolver(
|
||||
guid => d.MotionBindings.ResolvePhysicsHost(guid));
|
||||
// C3c: the graphical first-entry drive controller — walks every
|
||||
// initial-Create residence through its Runtime conductor with the
|
||||
// production prepared-collision source, the live movement-skill
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
|
|
@ -60,6 +61,14 @@ internal sealed class PlayerInteractionMovementSink(
|
|||
Height = approach.TargetHeight,
|
||||
};
|
||||
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] BeginApproach guid=0x{approach.Target.ServerGuid:X8} "
|
||||
+ $"movementType={movement.Type} distanceToObject={parameters.DistanceToObject} "
|
||||
+ $"canCharge={parameters.CanCharge} target=0x{movement.ObjectId:X8}");
|
||||
}
|
||||
|
||||
// PerformMovement cancels at its head. Do it explicitly before the
|
||||
// intent is armed so cancellation of the preceding move cannot clear
|
||||
// the new request; the internal second call is then a retail no-op.
|
||||
|
|
|
|||
|
|
@ -173,10 +173,17 @@ internal sealed class SelectionInteractionController
|
|||
// Use. Equipped-child picking makes that click reachable, so the gate
|
||||
// ships with it.
|
||||
if (useImmediately && !_query.IsWieldedByPlayer(guid))
|
||||
{
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] route=dblclick-world PickAndStoreSelection guid=0x{guid:X8} enqueue=Activate");
|
||||
}
|
||||
EnqueueIdentityBound(
|
||||
RuntimeQueuedInteractionKind.Activate,
|
||||
guid,
|
||||
requireLiveEntity: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -205,6 +212,11 @@ internal sealed class SelectionInteractionController
|
|||
_toast?.Invoke("Nothing selected");
|
||||
return;
|
||||
}
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] route=keyboard-use UseCurrentSelection guid=0x{selected:X8} enqueue=Use");
|
||||
}
|
||||
EnqueueIdentityBound(
|
||||
RuntimeQueuedInteractionKind.Use,
|
||||
selected,
|
||||
|
|
@ -260,10 +272,22 @@ internal sealed class SelectionInteractionController
|
|||
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
|
||||
bool useable = ownedByPlayer || _query.IsUseable(serverGuid);
|
||||
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] RequestUse entry guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable}");
|
||||
}
|
||||
|
||||
if (useable
|
||||
&& _query.TryGetApproach(serverGuid, out InteractionApproach approach)
|
||||
&& !approach.IsCloseRange)
|
||||
{
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=approach-armed useRadius={approach.UseRadius} isCloseRange={approach.IsCloseRange}");
|
||||
}
|
||||
|
||||
// Genuinely out of range (a real walk, not just a turn) —
|
||||
// mirror SendPickup's arrival-gated shape: arm the transaction
|
||||
// on the approach token BEFORE the movement starts (so a
|
||||
|
|
@ -283,7 +307,17 @@ internal sealed class SelectionInteractionController
|
|||
token.ControllerLifetime,
|
||||
token.ApproachGeneration),
|
||||
out _);
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] TryArmPostArrivalUse guid=0x{serverGuid:X8} armed={armed} approachToken=({token.ControllerLifetime},{token.ApproachGeneration})");
|
||||
}
|
||||
});
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] BeginApproach-result guid=0x{serverGuid:X8} started={started} armed={armed} stopDistance={approach.UseRadius} target=0x{approach.Target.ServerGuid:X8}");
|
||||
}
|
||||
if (!started || !armed)
|
||||
{
|
||||
// Release whatever got captured (or the caller's own
|
||||
|
|
@ -306,6 +340,11 @@ internal sealed class SelectionInteractionController
|
|||
// — keep retail's immediate send; ACE's own "already within use
|
||||
// distance" branch (Player_Move.cs:65-87) calls back synchronously,
|
||||
// so there is no arrival gap to race here.
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=immediate-dispatch ownedByPlayer={ownedByPlayer} useable={useable}");
|
||||
}
|
||||
RuntimeInteractionDispatchResult result =
|
||||
_transactions.TryDispatchUse(
|
||||
serverGuid,
|
||||
|
|
@ -314,6 +353,11 @@ internal sealed class SelectionInteractionController
|
|||
reservation,
|
||||
_transport,
|
||||
out uint sequence);
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} TryDispatchUse verdict={result} seq={sequence}");
|
||||
}
|
||||
if (result == RuntimeInteractionDispatchResult.NotInWorld)
|
||||
_toast?.Invoke("Not in world");
|
||||
if (result == RuntimeInteractionDispatchResult.Dispatched)
|
||||
|
|
@ -545,6 +589,22 @@ internal sealed class SelectionInteractionController
|
|||
RuntimePendingUse pending,
|
||||
bool accepted)
|
||||
{
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
// Diagnostic-only re-query — TryGetApproach is a pure read with
|
||||
// no side effects, so an extra call here (gated off in
|
||||
// production) cannot change RequestUse's own dispatch outcome.
|
||||
string distanceText = "n/a";
|
||||
if (_query.TryGetApproach(pending.ServerGuid, out InteractionApproach diagApproach))
|
||||
{
|
||||
float dx = diagApproach.Target.Entity.Position.X - diagApproach.Player.Position.X;
|
||||
float dy = diagApproach.Target.Entity.Position.Y - diagApproach.Player.Position.Y;
|
||||
distanceText = MathF.Sqrt(dx * dx + dy * dy).ToString("F2");
|
||||
}
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] HandleUseApproachCompletion guid=0x{pending.ServerGuid:X8} accepted={accepted} playerToTargetDist={distanceText}");
|
||||
}
|
||||
|
||||
if (!accepted)
|
||||
{
|
||||
pending.Reservation?.CancelBeforeDispatch();
|
||||
|
|
|
|||
|
|
@ -567,8 +567,16 @@ internal sealed class WorldSelectionQuery
|
|||
/// within ACE's real UseRadius.
|
||||
/// </remarks>
|
||||
private float GetUseRadius(uint serverGuid)
|
||||
=> _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
&& spawn.UseRadius is > 0f
|
||||
? spawn.UseRadius.Value
|
||||
: DefaultUseRadius;
|
||||
{
|
||||
bool haveSpawn = _liveEntities.TryGetSnapshot(serverGuid, out var spawn);
|
||||
bool fromWire = haveSpawn && spawn.UseRadius is > 0f;
|
||||
float radius = fromWire ? spawn.UseRadius!.Value : DefaultUseRadius;
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] GetUseRadius guid=0x{serverGuid:X8} radius={radius} "
|
||||
+ $"source={(fromWire ? "wire" : "fallback-0.6")}");
|
||||
}
|
||||
return radius;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -509,6 +509,11 @@ public sealed class ItemInteractionController : IDisposable
|
|||
switch (mode)
|
||||
{
|
||||
case InteractionModeKind.Use:
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] route=target-mode-click OfferPrimaryClick(Use) guid=0x{targetGuid:X8}");
|
||||
}
|
||||
ClearTargetMode();
|
||||
accepted = ActivateItem(targetGuid);
|
||||
break;
|
||||
|
|
@ -541,6 +546,11 @@ public sealed class ItemInteractionController : IDisposable
|
|||
/// </summary>
|
||||
public bool UseSelectedOrEnterMode(uint selectedObjectId)
|
||||
{
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] route=toolbar-or-queued-use UseSelectedOrEnterMode guid=0x{selectedObjectId:X8}");
|
||||
}
|
||||
if (selectedObjectId != 0)
|
||||
return ActivateItem(selectedObjectId);
|
||||
return _interactionState.EnterUse();
|
||||
|
|
@ -640,6 +650,11 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
public bool ActivateItem(uint itemGuid)
|
||||
{
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] entry:ActivateItem guid=0x{itemGuid:X8} isTargetModeActive={IsTargetModeActive}");
|
||||
}
|
||||
if (itemGuid == 0) return false;
|
||||
|
||||
if (IsTargetModeActive)
|
||||
|
|
@ -966,6 +981,11 @@ public sealed class ItemInteractionController : IDisposable
|
|||
/// </summary>
|
||||
public bool ExecuteConfirmedUse(uint objectId)
|
||||
{
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] route=confirmed-use ExecuteConfirmedUse guid=0x{objectId:X8}");
|
||||
}
|
||||
if (objectId == 0u || (_requestUse is null && _sendUse is null))
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
|
|
|
|||
|
|
@ -346,6 +346,13 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
? $"{stackSize} {objectName}"
|
||||
: objectName;
|
||||
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] ApplySelection guid=0x{g:X8} stackSizeOperand={stackSize} "
|
||||
+ $"objectName={objectName ?? "null"} builtLabel={_currentName ?? "null"}");
|
||||
}
|
||||
|
||||
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
|
||||
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
|
||||
SetOverlayState(stackSize > 1u
|
||||
|
|
@ -367,10 +374,23 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
// starting VALUE differs, not the ceiling.
|
||||
if (stackSize > 1u)
|
||||
{
|
||||
uint seed = _isVendorSplitExempt(g) ? 1u : stackSize;
|
||||
bool vendorSplitExempt = _isVendorSplitExempt(g);
|
||||
uint seed = vendorSplitExempt ? 1u : stackSize;
|
||||
_splitQuantity.Reset(stackSize, initialValue: seed);
|
||||
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true;
|
||||
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true;
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=true "
|
||||
+ $"isVendorSplitExempt={vendorSplitExempt} maxSplitSize={stackSize} seed={seed}");
|
||||
}
|
||||
}
|
||||
else if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=false "
|
||||
+ $"failingPredicate=stackSize<=1u stackSize={stackSize}");
|
||||
}
|
||||
|
||||
// ── 4. Health: query, and show the meter only if real health is already known.
|
||||
|
|
|
|||
|
|
@ -1401,15 +1401,18 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// this one — otherwise the CURRENT slider value via
|
||||
/// <c>ItemHolder::GetObjectSplitSize</c> (<c>0x00586F00</c>).
|
||||
/// <para>
|
||||
/// R1 gate-finding fix (2026-08-08, register AP-169 correction): the
|
||||
/// ceiling this gates on is <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/>
|
||||
/// — <see cref="VendorShopItem.DescStackSize"/> when the wire carries
|
||||
/// it, else <see cref="VendorShopItem.MaxStackSize"/> — the SAME
|
||||
/// resolution <see cref="VendorShopItemMaterializer"/> uses to seed
|
||||
/// <see cref="ClientObject.StackSize"/> (and therefore the toolbar
|
||||
/// slider's own ceiling, <c>SelectedObjectController</c>). Using a
|
||||
/// narrower source here than the visible slider would let the slider
|
||||
/// show a ceiling of 1000 while every Buy still sent quantity 1.
|
||||
/// R1 gate-finding fix, CORRECTED at the 2026-08-08 live-evidence
|
||||
/// re-fix (register AP-169, second correction): the ceiling this gates
|
||||
/// on is <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> —
|
||||
/// <see cref="VendorShopItem.MaxStackSize"/> FIRST (retail's own
|
||||
/// vendor-owned quantity operand: the Buy cases at
|
||||
/// <c>pc:203996</c>/<c>204086</c> gate on <c>pwd._maxStackSize > 1</c>
|
||||
/// literally), falling back to <see cref="VendorShopItem.DescStackSize"/>
|
||||
/// — the SAME resolution <see cref="VendorShopItemMaterializer"/> uses
|
||||
/// to seed <see cref="ClientObject.StackSize"/> (and therefore the
|
||||
/// toolbar slider's own ceiling, <c>SelectedObjectController</c>).
|
||||
/// Using a narrower source here than the visible slider would let the
|
||||
/// slider show a ceiling of 1000 while every Buy still sent quantity 1.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private uint ResolveBuyQuantity(VendorShopItem item)
|
||||
|
|
|
|||
|
|
@ -394,6 +394,13 @@ public static class GameEventWiring
|
|||
registrar.Register(GameEventType.ApproachVendor, e =>
|
||||
{
|
||||
var p = VendorApproach.TryParse(e.Payload.Span);
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] ApproachVendor(0x0062) inbound parsed={p is not null} "
|
||||
+ $"vendorGuid={(p is null ? "n/a" : $"0x{p.Value.VendorGuid:X8}")} "
|
||||
+ $"itemCount={(p is null ? "n/a" : p.Value.Items.Count.ToString())}");
|
||||
}
|
||||
if (p is null) return;
|
||||
|
||||
var profile = new VendorShopProfile(
|
||||
|
|
@ -411,6 +418,14 @@ public static class GameEventWiring
|
|||
for (int i = 0; i < shopItems.Length; i++)
|
||||
{
|
||||
VendorApproach.ItemProfile item = p.Value.Items[i];
|
||||
if (VendorDiagnostics.DumpVendorEnabled && i < 5)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] ApproachVendor wire-item[{i}] guid=0x{item.ItemGuid:X8} "
|
||||
+ $"name={item.Desc.Name ?? "null"} "
|
||||
+ $"descStackSize={(item.Desc.StackSize is { } ds ? ds.ToString() : "null")} "
|
||||
+ $"stackSizeMax={(item.Desc.StackSizeMax is { } sm ? sm.ToString() : "null")}");
|
||||
}
|
||||
shopItems[i] = new VendorShopItem(
|
||||
item.ItemGuid,
|
||||
item.StackSize,
|
||||
|
|
@ -502,6 +517,12 @@ public static class GameEventWiring
|
|||
registrar.Register(GameEventType.UseDone, e =>
|
||||
{
|
||||
uint? err = GameEvents.ParseUseDone(e.Payload.Span);
|
||||
if (VendorDiagnostics.DumpVendorEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[vendor-diag] UseDone(0x01C7) inbound parsed={err is not null} "
|
||||
+ $"err={(err is null ? "n/a" : $"0x{err.Value:X4}")}");
|
||||
}
|
||||
if (err is null) return;
|
||||
Console.WriteLine($"[use-done] err=0x{err.Value:X4}");
|
||||
onUseDone?.Invoke(err.Value);
|
||||
|
|
|
|||
27
src/AcDream.Core/Items/VendorDiagnostics.cs
Normal file
27
src/AcDream.Core/Items/VendorDiagnostics.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// TEMPORARY diagnostic-probe owner for two live-only regressions that have
|
||||
/// each survived two green-tested fixes: Chain A (a far-click walk-to-use
|
||||
/// approaches a vendor, the vendor plays its cosmetic greeting, but the shop
|
||||
/// window never opens) and Chain B (selecting a splittable vendor stack
|
||||
/// shows the bare item name with no quantity slider). Every probe line is
|
||||
/// prefixed <c>[vendor-diag]</c> and gated on <see cref="DumpVendorEnabled"/>
|
||||
/// so the family is a true no-op when the flag is unset.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read once from <c>ACDREAM_DUMP_VENDOR=1</c> at process start, per Code
|
||||
/// Structure Rule 5 (one static diagnostic-owner class per subsystem, no
|
||||
/// per-call-site <see cref="Environment.GetEnvironmentVariable"/> reads).
|
||||
/// Lives in <c>AcDream.Core</c> — the one project every call site (App,
|
||||
/// Core.Net, Runtime) already references — so a single flag instance is
|
||||
/// shared across the whole probe family regardless of which layer observes
|
||||
/// it first.
|
||||
/// </remarks>
|
||||
public static class VendorDiagnostics
|
||||
{
|
||||
public static bool DumpVendorEnabled { get; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_VENDOR") == "1";
|
||||
}
|
||||
|
|
@ -44,35 +44,57 @@ public static class VendorSplitPolicy
|
|||
: authoredStackSize is { } size && size > 0 ? size : 1;
|
||||
|
||||
/// <summary>
|
||||
/// Grand-gate finding R1 (2026-08-08, register AP-169 correction): the
|
||||
/// retail-faithful "how big is one stack of this item" answer for a
|
||||
/// VENDOR-owned selection, used everywhere retail reads
|
||||
/// <c>PublicWeenieDesc::_stackSize</c> for splitting/seeding purposes
|
||||
/// (<c>gmToolbarUI::HandleSelectionChanged</c>,
|
||||
/// <c>pc:198688</c>/<c>198744</c>/<c>198774</c>/<c>198791</c>;
|
||||
/// <c>ItemHolder::GetObjectSplitSize</c>, <c>pc:401465-401477</c>).
|
||||
/// Grand-gate finding R1, CORRECTED at the 2026-08-08 live-evidence
|
||||
/// re-fix (register AP-169, second correction): the retail-faithful
|
||||
/// "how big is one stack of this item" quantity operand for a
|
||||
/// VENDOR-owned selection/listing.
|
||||
///
|
||||
/// <para>
|
||||
/// Prefers <paramref name="descStackSize"/> (the wire's own
|
||||
/// <c>PublicWeenieDesc::_stackSize</c> — retail-faithful FIRST, honored
|
||||
/// unchanged if a real retail server or a future ACE fix ever populates
|
||||
/// it). ACE never sets it for a vendor browse-list row
|
||||
/// (<c>Vendor.LoadInventoryItem</c> never calls <c>wo.SetStackSize</c>),
|
||||
/// so this falls back to <paramref name="maxStackSize"/> — the item
|
||||
/// TYPE's authored stack ceiling, which ACE DOES populate (an ordinary
|
||||
/// weenie property, not an instance-specific stack count) and which a
|
||||
/// real retail server evidently uses AS <c>_stackSize</c> for an
|
||||
/// unlimited-supply "one full stack" browse listing (the live retail
|
||||
/// screenshot: a Prismatic Taper listing shows "1000 Prismatic Tapers",
|
||||
/// 1000 being the taper's authored max stack size, not any bounded
|
||||
/// supply count). Finally falls back to 1 (non-splittable) when neither
|
||||
/// field is available. See the register, AP-169.
|
||||
/// <b>The operand is <paramref name="maxStackSize"/> FIRST.</b> Retail's
|
||||
/// own vendor-owned quantity sites read
|
||||
/// <c>PublicWeenieDesc::_maxStackSize</c> DIRECTLY, never the instance
|
||||
/// <c>_stackSize</c>: <c>VendorItemsUI::UpdateItemsList</c>
|
||||
/// (<c>0x004c1ea0</c>, <c>pc:201085-201133</c>) displays each browse
|
||||
/// row's quantity as <c>pwd._maxStackSize</c> (clamped by the packed
|
||||
/// supply count only when that count is finite —
|
||||
/// <c>SetObjectStackSize(this, obj, min(remaining, _maxStackSize))</c>;
|
||||
/// an unlimited <c>0xffffffff</c> listing shows plain
|
||||
/// <c>_maxStackSize</c>); <c>gmVendorUI::InqListSlotCount</c>
|
||||
/// (<c>0x004c0c10</c>, <c>pc:200052</c>) classifies list rows on
|
||||
/// <c>pwd._maxStackSize <= 1</c>; and the Buy button cases
|
||||
/// (<c>gmVendorUI::HandleButtonClicks</c> cases <c>0x100000c9</c> at
|
||||
/// <c>pc:203996</c> / <c>0x100000cb</c> at <c>pc:204086</c>) gate the
|
||||
/// stackable-buy path on <c>pwd._maxStackSize > 1</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why desc-first was wrong (the live evidence).</b> The R1 version
|
||||
/// of this method preferred <paramref name="descStackSize"/> on the
|
||||
/// theory that ACE never populates it for a browse row. The 2026-08-08
|
||||
/// vendor-diag live run refuted that: ACE serializes
|
||||
/// <c>descStackSize=1</c> for EVERY browse row (`[vendor-diag]
|
||||
/// ApproachVendor wire-item[...] descStackSize=1 stackSizeMax=100`), so
|
||||
/// desc-first resolved every vendor stack to 1 and the toolbar split
|
||||
/// slider never appeared (`ApplySelection ... failingPredicate=
|
||||
/// stackSize<=1u stackSize=1`). Max-first matches both retail's own
|
||||
/// operand and the live retail screenshot ("1000 Prismatic Tapers",
|
||||
/// ceiling 1000 = the taper's authored max stack size).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <paramref name="descStackSize"/> remains the fallback when no
|
||||
/// authored ceiling exists, then 1 (non-splittable). This helper is
|
||||
/// consumed ONLY by the vendor-owned paths
|
||||
/// (<c>VendorShopItemMaterializer.ToWeenieData</c> and
|
||||
/// <c>VendorUiController.ResolveBuyQuantity</c>); player-inventory
|
||||
/// stacks read their own wire <c>ClientObject.StackSize</c> and are
|
||||
/// deliberately untouched by this rule. See the register, AP-169.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static int ResolveAuthoredStackSize(int? descStackSize, int? maxStackSize) =>
|
||||
descStackSize is { } desc && desc > 0
|
||||
? desc
|
||||
: maxStackSize is { } max && max > 0
|
||||
? max
|
||||
maxStackSize is { } max && max > 0
|
||||
? max
|
||||
: descStackSize is { } desc && desc > 0
|
||||
? desc
|
||||
: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,22 +58,21 @@ public readonly record struct VendorShopItem(
|
|||
// matching retail's own zeroed-struct default of 0 for the same case
|
||||
// (see VendorPricing.PerUnitValue's <= 0 guard).
|
||||
int? DescStackSize = null,
|
||||
// Grand-gate finding R1 (2026-08-08, register AP-169 correction): the
|
||||
// item TYPE's authored stack ceiling (retail PublicWeenieDesc::
|
||||
// _maxStackSize, wire AcDream.Core.Net.Messages.PublicWeenieDescBody.
|
||||
// StackSizeMax). Retail's OWN InqListSlotCount (pc:200038-200065) and
|
||||
// the Buying tab's Buy Item/Clear Item removal rule
|
||||
// Grand-gate finding R1, corrected at the 2026-08-08 live-evidence
|
||||
// re-fix (register AP-169, second correction): the item TYPE's authored
|
||||
// stack ceiling (retail PublicWeenieDesc::_maxStackSize, wire
|
||||
// AcDream.Core.Net.Messages.PublicWeenieDescBody.StackSizeMax). This is
|
||||
// retail's PRIMARY vendor-owned quantity operand, read literally by
|
||||
// VendorItemsUI::UpdateItemsList (0x004c1ea0, pc:201085-201133 — each
|
||||
// browse row displays min(remaining, _maxStackSize), plain
|
||||
// _maxStackSize for unlimited supply), InqListSlotCount (pc:200052),
|
||||
// and the Buying tab's Buy Item/Clear Item removal rule
|
||||
// (gmVendorUI::HandleButtonClicks cases 0x100000c9/0x100000cb,
|
||||
// pc:203989-204010/204080-204094) read THIS field literally, never
|
||||
// DescStackSize, to decide whether an item is stackable at all. It also
|
||||
// serves as this port's retail-faithful SUBSTITUTE for the toolbar's
|
||||
// splitSize/maxSplitSize seed (gmToolbarUI::HandleSelectionChanged,
|
||||
// pc:198688/198744/198774/198791 — reads PublicWeenieDesc::_stackSize,
|
||||
// which ACE never populates for a vendor browse-list row; a real retail
|
||||
// server evidently authors _stackSize == _maxStackSize for an
|
||||
// unlimited-supply "one full stack" browse listing, so MaxStackSize is
|
||||
// the value retail's own server would have put there) — see
|
||||
// VendorSplitPolicy.ResolveAuthoredStackSize and the register (AP-169).
|
||||
// pc:203996/204086) — never the instance _stackSize. The live ACE wire
|
||||
// (vendor-diag 2026-08-08) carries descStackSize=1 for every browse
|
||||
// row, so any desc-first read collapses every vendor stack to 1 — see
|
||||
// VendorSplitPolicy.ResolveAuthoredStackSize (max-first, desc
|
||||
// fallback) and the register (AP-169).
|
||||
int? MaxStackSize = null,
|
||||
// Review finding F5 (Slice 5.4 review): PublicWeenieDescBody already
|
||||
// carries these three (IconOverlayId/IconUnderlayId/UiEffects) — see
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue