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
|
|
@ -296,7 +296,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-166 | **Filed 2026-08-09, Slice 6b/6c (staging presentation).** **NARROWED 2026-08-08 (grand-gate re-gate finding R3) — the text half CLOSES.** The Buying/Selling tabs' own staged-count/total-value text (`m_buyListText`/`m_sellListText`, D0 ids `0x100000C7`/`0x100000D0`) and purse-total text (`m_buyPurseText`/`m_sellPurseText`, `0x100000C8`/`0x100000D1`) are now wired (`VendorUiController.UpdateBuyTransactionText`/`UpdateSellTransactionText`), updating on every staging change and on every player money change. Retail's exact literals were recovered: the pyreal-path strings are byte-verbatim — `"Buying %d %s worth %hsp"` / `"Selling %d %s worth %hsp"` (the Sell literal is directly legible in the decompiled body of `VendorSellUI::UpdateTransactionValue`, `pc:202458`; the identically-shaped Buy literal is mis-attributed by the decompiler to a bogus vtable-slot symbol at its own call site, `pc:202290`, so it was recovered instead by reading the retail binary's own data segment directly at VA `0x007b58bc`, cross-confirmed byte-for-byte against the Sell literal's own VA `0x007b5930`) and `"You have %hsp"` (directly legible at both `VendorBuyUI::UpdateTotalValue` `pc:202366` and `VendorSellUI::UpdateTotalValue` `pc:202495`, byte-identical). The alt-currency PURSE literal `"You have %d %s."` is also directly legible (`VendorBuyUI::UpdateTotalValue`, `pc:202344`) and ported; the alt-currency LIST-line construction is a faithful extrapolation of the confirmed pyreal shape, NOT independently byte-verified — this one narrow piece remains open under this row (a rare trade-note-vendor case). The row's SECOND original gap — a successful Sell Item/Sell All/Clear Item does not port retail's cross-panel `gmVendorUI::VendorItemSetSellState` (the player's OWN inventory panel highlight marking an item "pending sell") — is UNCHANGED, still open. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`BuildTransactionListText`, `BuildPurseText`, `UpdateBuyTransactionText`, `UpdateSellTransactionText`, `ComputeSellTransactionValue`, `OnObjectMoneyChanged`) | The alt-currency LIST-line residual is narrow (a rare trade-note vendor) and the pyreal path — the live-evidence report's own case — is now byte-exact; the pending-sell inventory highlight is a separate, unrelated mechanism this pass did not attempt. | An alt-currency vendor's Buying/Selling LIST line may not match retail's exact wording (the purse line does, and the pyreal path's LIST line does); a pending-sell item still shows no visual cue back in the main inventory panel while staged. | `VendorBuyUI::VendorBuyUI` `pc:199717`; `VendorSellUI::VendorSellUI` `pc:199753` (purse/list text element construction); `VendorBuyUI::UpdateTransactionValue` `pc:202170-202300`; `VendorBuyUI::UpdateTotalValue` `pc:202304-202376`; `VendorSellUI::UpdateTransactionValue` `pc:202380-202468`; `VendorSellUI::UpdateTotalValue` `pc:202472-202504`; `gmVendorUI::VendorItemSetSellState` (call sites `pc:204107`/`204133`); acclient.exe (Sept 2013 EoR, PDB-paired) data segment VA `0x007b58bc`/`0x007b5930`; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4 D0 tree |
|
||||
| AP-167 | **Filed 2026-08-09, Opus review of `92ea3977`, finding F6 (Sell Item's SellSingleItem port).** Retail's `gmVendorUI::SellSingleItem` (`pc:201808-201881`, `0x004c2b40`) gates its whole stack-split/send branch behind an OUTER check: if the selected item is container-capable (a bitfield bit this port does not currently decode, ORed with nonzero `_itemsCapacity`/`_containersCapacity`) AND it currently holds contents, `SellSingleItem` refuses with a distinct notice (`RecvNotice_SkillAdvancementClassChanged`'s literal string, not yet recovered) and never reaches the stack-split check or the send at all — matching `InqAcceptability`'s own "a non-empty container always accepted" bypass being the WRONG direction for a DIRECT single-item sell of the CONTAINER itself. `VendorUiController.SellItemButtonPressed` does not port this outer branch — it goes straight to the stack-split check for every selected item, container or not. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`SellItemButtonPressed`) | The scope of the review finding (F6) was the stack-split refusal and the literal amount-1 send, both fully ported; the container-emptiness branch is a distinct, separately-gated retail mechanism this pass did not trace far enough to port (the exact bitfield bit and the refusal string are both still unrecovered). The server remains authoritative regardless — a client-side accept here is a UX gap, not a wire-safety one. | Selecting a non-empty container (a bag with items still inside it) and pressing "Sell Item" directly would, in retail, refuse locally with a distinct message; this port instead falls through to the ordinary stack-split check (which a non-stackable container passes trivially) and sends the sell — the actual sale's server-side fate for a non-empty container is untraced (ACE may reject, merge, or drop the contents; not investigated here). | `gmVendorUI::SellSingleItem` `pc:201808-201881`/`0x004c2b40` (outer container-emptiness branch `pc:201818-201829`); `docs/research/2026-08-08-slice6b-vendor-completion-research.md` |
|
||||
| AP-168 | **NARROWED 2026-08-08 (grand-gate finding G1) — the player's-OWN-pack half (`CountPlayerContents`) is FIXED; only the shop-stock half (`ComputeBuySlotsNeeded`) remains approximated.** Live testing surfaced the risk this row already predicted: "Buy All" false-blocked a container purchase while the player visibly had free container slots. Root cause was NOT the theoretical corner case originally described here — it was that the old dual-heuristic (`ItemType.Container` bit OR nonzero `ItemsCapacity`/`ContainersCapacity`) could over-classify an ordinary non-container object as an occupied container slot, undercounting free space. `CountPlayerContents` now reads `ClientObject.ContainerTypeHint` first — retail's actual wire `ContainerProperties` (`Item_ServerSaysContainId` 0x0022's `ContainerType`; also carried by `ContentProfile`/`PlayerDescription`'s per-entry container-kind byte), already threaded onto every owned object by `InitializeInventoryManifest`/`ApplyConfirmedServerMove`/`ReplaceContents` and already used for this identical question by `ClientObjectTable.IsContainerListMember` — falling back to `ItemType.Container` alone (the capacity-field legs were dropped) only for the rare object that never received a hint. This matches retail's real `_itemsList`/`_containersList` bucketing (`ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` @0x0058beb0/0x0058bec0 just report already-bucketed `IDList` lengths; the bucketing happens once, at insert time, in `ServerSaysContainID` @0x0058be40, from that same wire field) rather than reconstructing it from the item's own type/capacity fields. Original text: **Filed 2026-08-09, Opus review of `92ea3977`, finding F1 (Buy All's client pre-send capacity guard).** Retail's `gmVendorUI::InqListSlotCount` (`pc:200038-200065`, `0x004c0c10`) classifies each staged item as needing a CONTAINER slot or an ITEM slot by testing a bitfield bit (a decompiler string-misattribution artifact not yet decoded) ORed with the item's own nonzero `_itemsCapacity`/`_containersCapacity`. `VendorUiController.ComputeBuySlotsNeeded`/`CountPlayerContents` approximate this with `(item.ItemType & ItemType.Container) != 0` instead — correct for the ordinary case (an authored backpack/pouch DOES carry the `Container` type bit) but not byte-identical for the theoretical case of a non-`Container`-typed item that still authors nonzero pack/side capacities (or vice versa, a `Container`-typed item with zero capacity of its own, e.g. a locked/sealed decorative chest never meant to be carried). | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ComputeBuySlotsNeeded`, `CountPlayerContents`) | `VendorShopItem`'s wire shape (Slice 5's deliberately narrow browse-scope subset) genuinely does not carry `PublicWeenieBitfield`/`ItemsCapacity`/`ContainersCapacity`/`ContainerProperties` the way `ClientObject` does for an ordinary `CreateObject`/membership-sourced item, so `ComputeBuySlotsNeeded` (the shop-stock side, staged-but-not-yet-owned items) cannot read a wire-truth hint the way the fixed `CountPlayerContents` (the already-owned side) now does; extending the DTO was out of scope for this fix. The server remains authoritative and re-validates real pack-space regardless (`Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`) — the residual failure mode stays UX/latency, not correctness. | A vendor selling a `Container`-typed item with zero authored capacity (rare/decorative) would still be misclassified as needing a container slot instead of an item slot, or vice versa for a non-`Container`-typed item that DOES author capacity (also rare) — the pre-check could still reject a purchase retail's own guard would have allowed, or allow one retail would have blocked, purely on the CLIENT side for the SHOP-STOCK item being bought; the player's-OWN-pack accounting that drives the free-slot count is no longer the source of that risk. | `gmVendorUI::InqListSlotCount` `pc:200038-200065`/`0x004c0c10`; `ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` `0x0058beb0`/`0x0058bec0`; `ACCWeenieObject::ServerSaysContainID` `0x0058be40`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` |
|
||||
| AP-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live).** **CORRECTED 2026-08-08 (re-gate finding R1) — the G2 fallback story was itself wrong; the fix below replaces it.** Retail's `gmToolbarUI::HandleSelectionChanged` reads `eax_5->pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) uniformly for both owned-inventory and vendor-owned selections — the same `PublicWeenieDesc::_stackSize` field an ordinary `CreateObject` carries. The user's local ACE server never populates that field for a vendor browse-list row: `Vendor.LoadInventoryItem` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:144-172`) builds the listing `WorldObject` via `WorldObjectFactory.CreateNewWorldObject` and sets only `wo.VendorShopCreateListStackSize` (our `VendorShopItem.StackSize`, the packed ItemProfile "how many for sale" dword) — it never calls `wo.SetStackSize(...)`, so `PublicWeenieDesc`'s own conditional `StackSize` field (our `VendorShopItem.DescStackSize`) comes back null on the real wire for every vendor listing. The G2 fix's fallback — the packed `StackSize` supply-count field — did NOT survive live re-testing: a standard vendor listing (e.g. a Prismatic Taper) has UNLIMITED stock (`StackSize == -1`), so the fallback produced nothing usable and the bar stayed hidden, matching the live re-test report exactly (bare "Prismatic Taper", no bar, no count). The live retail screenshot that triggered the re-test 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: a real retail server evidently authors `_stackSize == _maxStackSize` for an unlimited-supply "one full stack" browse listing. `VendorShopItem` now carries `MaxStackSize` (threaded from `PublicWeenieDescBody.StackSizeMax`, itself already parsed but previously never forwarded); `VendorShopItemMaterializer.ToWeenieData` and `VendorUiController.ResolveBuyQuantity` both now resolve through the single shared `VendorSplitPolicy.ResolveAuthoredStackSize(descStackSize, maxStackSize)` helper — `DescStackSize` when the wire genuinely carries it (retail-faithful first, still honored unchanged for any server that DOES populate it), else `MaxStackSize` (the field ACE DOES reliably send), else 1. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`ToWeenieData`); `src/AcDream.Core/Items/VendorSplitPolicy.cs` (`ResolveAuthoredStackSize`); `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ResolveBuyQuantity`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's own client-side read (`pwd._stackSize`) is unchanged and still tried first; only the fallback source differs, and only because the live server this port talks to (the user has no other AC server available) never fills the field retail's own server apparently does. `MaxStackSize` is the value a real retail server would have put in `_stackSize` for this exact case (an unlimited-supply full-stack listing), not an arbitrary substitute. | A vendor stocking a bounded but non-unit quantity WITHOUT ACE ever populating `DescStackSize` shows a ceiling of the item's max stack size rather than its actual bounded stock count — the client no longer enforces "you can't buy more than what's in stock" via the slider ceiling itself; the server remains authoritative and rejects an over-large Buy regardless (a latency/UX gap, not a correctness one, matching this project's established pattern for client-side pre-checks — see AP-162). | `gmToolbarUI::HandleSelectionChanged` `pc:198635-198930`/`0x004bf380` (stack-size reads at `pc:198688`/`198744`/`198774`/`198791`); `Vendor.LoadInventoryItem` `Vendor.cs:144-172`; `GameEventApproachVendor.cs:52-61`; live retail screenshot evidence (2026-08-08 re-gate) |
|
||||
| AP-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live). CORRECTED 2026-08-08 (re-gate finding R1). CORRECTED AGAIN 2026-08-08 (live vendor-diag evidence) — both earlier stories mis-identified the operand; this row now records the third and evidence-pinned shape.** The G2 fix fell back to the packed ItemProfile supply-count dword (unusable: a standard listing has UNLIMITED stock, `-1`). The R1 fix preferred the wire `PublicWeenieDesc::_stackSize` (`VendorShopItem.DescStackSize`) on the claim that ACE never populates it for a browse row — the live vendor-diag run REFUTED that claim: ACE serializes `descStackSize=1` for EVERY browse row (`[vendor-diag] ApproachVendor wire-item[...] descStackSize=1 stackSizeMax=100`), so desc-first resolved every vendor stack to 1 and the split bar never appeared (`ApplySelection ... failingPredicate=stackSize<=1u stackSize=1`). The named decomp settles what retail actually reads at its VENDOR-owned quantity sites: `pwd._maxStackSize` DIRECTLY — `VendorItemsUI::UpdateItemsList` (`0x004c1ea0`, `pc:201085-201133`) displays each browse row's quantity as `min(remaining, _maxStackSize)` (plain `_maxStackSize` for an unlimited listing, via `VendorSubUI::SetObjectStackSize`); `gmVendorUI::InqListSlotCount` (`0x004c0c10`, `pc:200052`) classifies rows on `pwd._maxStackSize <= 1`; the Buy cases (`gmVendorUI::HandleButtonClicks` `0x100000c9` @`pc:203996` / `0x100000cb` @`pc:204086`) gate the stackable-buy path on `pwd._maxStackSize > 1`. `VendorSplitPolicy.ResolveAuthoredStackSize(descStackSize, maxStackSize)` is therefore **max-first** (desc fallback, then 1), consumed only by the vendor-owned paths (`VendorShopItemMaterializer.ToWeenieData`, `VendorUiController.ResolveBuyQuantity`); player-inventory stacks never route through it. Matches the live retail screenshot ("1000 Prismatic Tapers", ceiling 1000 = the taper's authored max stack size). The toolbar-side `gmToolbarUI::HandleSelectionChanged` does read `pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) — on a REAL retail server the two agree for a browse row (the vendor UI stamps the displayed stack from `_maxStackSize`); against ACE (desc always 1) the `_maxStackSize` operand is the one that carries retail's meaning. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`ToWeenieData`); `src/AcDream.Core/Items/VendorSplitPolicy.cs` (`ResolveAuthoredStackSize`); `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ResolveBuyQuantity`) | This is an ACE-server-constraint adaptation on the toolbar leg only: retail's vendor UI reads `_maxStackSize` literally (ported as-is); the toolbar seed's `_stackSize` read is satisfied through the materialized `ClientObject.StackSize`, which this resolution stamps from `_maxStackSize` exactly as retail's own `UpdateItemsList` stamps the displayed stack — not an arbitrary substitute. | A vendor stocking a bounded but non-unit quantity shows a ceiling of `min` semantics only on a real retail server; against ACE the client-side slider ceiling is the authored max stack size, not the bounded stock count — the server remains authoritative and rejects an over-large Buy regardless (a latency/UX gap, not a correctness one — see AP-162). If ACE ever starts serializing a REAL per-listing `_stackSize` (not the constant 1), the max-first preference would hide it; the desc fallback fires only when no authored ceiling exists. | `VendorItemsUI::UpdateItemsList` `0x004c1ea0` `pc:201029-201133`; `gmVendorUI::InqListSlotCount` `0x004c0c10` `pc:200052`; `gmVendorUI::HandleButtonClicks` `pc:203996`/`204086`; `gmToolbarUI::HandleSelectionChanged` `pc:198688-198791`; live vendor-diag wire capture + live retail screenshot (2026-08-08) |
|
||||
| AP-170 | **Filed 2026-08-08, grand-gate finding G3 (out-of-range vendor Use lost silently).** Retail's `ItemHolder::UseObject @ 0x00588A80` has no client-side range check and sends Use immediately regardless of distance — this port's ORIGINAL `RequestUse` faithfully mirrored that shape. Live testing against the user's local ACE server showed it does not hold: walking to a vendor and using it from out of range plays the vendor's cosmetic greeting (a distance-only reaction, independent of Use) but never opens the shop panel — `ApproachVendor` never arrives. ACE's `Player.HandleActionUseItem` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`) explains why: an out-of-range target routes through `CreateMoveToChain(item, (success) => TryUseItem(item, success))` (`Player_Move.cs:37-96`), which polls every 0.1s for the player to reach `WithinUseRadius` and only then calls `ActOnUse` — it does not teleport or server-move the player; it waits for the CLIENT's own walk to land, and a Use that arrives before that poll ever starts observing an in-range player is simply never followed by the vendor's `ApproachVendor` send (`Vendor.ActOnUse`'s own doc comment: "the player will have been commanded to move using `DoMoveTo` before `ActOnUse` is called... it should be assumed that the player is within range" — a precondition our immediate send violated). `SelectionInteractionController.RequestUse` now arms the out-of-range case on the SAME arrival-gated shape `SendPickup`'s close-range (turn-only) branch already used (`RuntimeInteractionTransactionState.TryArmPostArrivalUse`/`TryResolveUseApproachCompletion`, mirroring `TryArmPostArrivalPickup`/`TryResolveApproachCompletion` field-for-field) — the wire Use dispatches only once the local approach naturally completes. An already-in-range Use (a turn at most, or no approach concept applies) is unaffected and still sends immediately, matching ACE's own "already within use distance" synchronous callback. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`RequestUse`, `HandleApproachCompletion`, `HandleUseApproachCompletion`, `CancelPendingApproach`, `OnEntityHidden`, `OnEntityRemoved`); `src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs` (`RuntimePendingUse`, `TryArmPostArrivalUse`, `TryResolveUseApproachCompletion`, `TryCancelPendingUse`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's REAL server walks the player itself before the target's `ActOnUse` ever sees the request, so the client's immediate send never races anything there. ACE does not do this for a player-initiated Use — it only polls and waits — so arming on arrival is required for correctness against the only server this port can test against, not a stylistic preference. | An interaction path that still calls `TryDispatchUse` directly without going through `RequestUse`'s approach gate (none identified at this fix) would keep the original race. The armed reservation is a live busy-count reference until arrival/cancellation resolves it; `ResetCore` releases it unconditionally on any reset/dispose so a teardown that runs without a preceding `CancelPendingApproach()` (e.g. a headless/no-window host with no `SelectionInteractionController`) cannot leak it. | `ItemHolder::UseObject` `0x00588A80`; `Player.HandleActionUseItem` `Player_Use.cs:176-215`; `Player.CreateMoveToChain`/`MoveToChain` `Player_Move.cs:37-153`; `Vendor.ActOnUse` `Vendor.cs:223-266` |
|
||||
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
|
||||
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
||||
|
|
|
|||
|
|
@ -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,11 +173,18 @@ 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>
|
||||
/// Retail SmartBox right-click path:
|
||||
|
|
@ -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
|
||||
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();
|
||||
return RuntimeInteractionDispatchResult.Dispatched;
|
||||
verdict = 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -696,8 +696,12 @@ public class SelectedObjectControllerTests
|
|||
/// operand is the item's authored <see cref="VendorShopItem.MaxStackSize"/>
|
||||
/// (1000 for a Prismatic Taper), not any supply count. This fixture now
|
||||
/// matches that exact ACE-realistic shape: unlimited packed supply, no
|
||||
/// <c>DescStackSize</c> (ACE never sets it — see the class doc above),
|
||||
/// <c>MaxStackSize=1000</c> from the wire.
|
||||
/// <c>DescStackSize</c>, <c>MaxStackSize=1000</c> from the wire.
|
||||
/// (Second correction, 2026-08-08: the live vendor-diag wire showed ACE
|
||||
/// actually serializes <c>descStackSize=1</c> for every browse row, so
|
||||
/// the resolver is now max-FIRST — see
|
||||
/// <see cref="LiveAceWireShape_DescOneMaxHundred_ShowsSplitSliderWithTheAuthoredCeiling"/>;
|
||||
/// this desc-absent fixture resolves identically either way.)
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
|
@ -744,23 +748,20 @@ public class SelectedObjectControllerTests
|
|||
&& vendorCandidate.ContainerId == vendor.VendorId
|
||||
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
|
||||
|
||||
// G2 root cause, R1-corrected: a REAL ACE vendor listing,
|
||||
// byte-for-byte. ACE's Vendor.LoadInventoryItem (Vendor.cs:144-172)
|
||||
// builds the browse-list WorldObject via
|
||||
// WorldObjectFactory.CreateNewWorldObject and sets ONLY
|
||||
// wo.VendorShopCreateListStackSize = stackSize ?? -1 (the "how many
|
||||
// available" packed dword — our VendorShopItem.StackSize) — it
|
||||
// NEVER calls wo.SetStackSize(...), so the per-item
|
||||
// PublicWeenieDesc's own conditional StackSize field (our
|
||||
// DescStackSize — retail's pwd._stackSize, what
|
||||
// GameEventApproachVendor.cs:60's SerializeGameDataOnly walks) comes
|
||||
// back null on the real wire. A STANDARD listing (unlimited stock,
|
||||
// G2 root cause, R1-corrected, second-corrected 2026-08-08: a REAL
|
||||
// ACE vendor listing. The R1 reading of Vendor.LoadInventoryItem
|
||||
// concluded DescStackSize "comes back null on the real wire"; the
|
||||
// live vendor-diag capture showed the real wire actually carries
|
||||
// descStackSize=1 for every browse row — either way the field never
|
||||
// names a usable ceiling. A STANDARD listing (unlimited stock,
|
||||
// packed StackSize=-1) is the common case the live re-test actually
|
||||
// hit — matching the retail screenshot's Prismatic Taper, not a
|
||||
// bounded-supply item like the original fixture's "100 arrows".
|
||||
// MaxStackSize=1000 IS reliably populated by ACE (an ordinary
|
||||
// weenie property), which is the field the toolbar ceiling now
|
||||
// resolves through — see VendorSplitPolicy.ResolveAuthoredStackSize.
|
||||
// weenie property), and it is retail's own vendor-side operand
|
||||
// (VendorItemsUI::UpdateItemsList 0x004c1ea0 stamps each row's
|
||||
// displayed stack from pwd._maxStackSize), which the resolver now
|
||||
// prefers — see VendorSplitPolicy.ResolveAuthoredStackSize.
|
||||
vendor.Apply(
|
||||
vendorGuid,
|
||||
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
|
||||
|
|
@ -788,6 +789,95 @@ public class SelectedObjectControllerTests
|
|||
controller.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-08 live-evidence re-fix (register AP-169, second
|
||||
/// correction): the EXACT live ACE wire shape the vendor-diag run
|
||||
/// captured — every browse row carries <c>descStackSize=1</c> AND
|
||||
/// <c>stackSizeMax</c> (e.g. a Lead Scarab: desc 1, max 100). The R1
|
||||
/// desc-first resolver read the 1 and the split bar never appeared
|
||||
/// (`ApplySelection ... sliderVisible=false
|
||||
/// failingPredicate=stackSize<=1u stackSize=1` in the live log).
|
||||
/// Retail's vendor UI reads <c>pwd._maxStackSize</c> directly
|
||||
/// (<c>VendorItemsUI::UpdateItemsList</c> <c>0x004c1ea0</c>), so the
|
||||
/// selection must show the bar with ceiling 100, seeded at 1 (scarabs
|
||||
/// are SpellComponents — split-exempt), and the retail "{count}
|
||||
/// {plural name}" label. Same REAL-materializer wiring as the G2 test
|
||||
/// above. Sabotage-verified: restoring the desc-first preference
|
||||
/// resolves the operand to 1 and every assertion below fails.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LiveAceWireShape_DescOneMaxHundred_ShowsSplitSliderWithTheAuthoredCeiling()
|
||||
{
|
||||
const uint vendorGuid = 0x70000012u;
|
||||
const uint scarabGuid = 0x60009012u;
|
||||
|
||||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||||
var objects = new ClientObjectTable();
|
||||
var vendor = new VendorState();
|
||||
var selection = new SelectionState();
|
||||
var splitQuantity = new StackSplitQuantityState();
|
||||
using var materializer = new VendorShopItemMaterializer(vendor, objects);
|
||||
|
||||
SelectedObjectController controller = SelectedObjectController.Bind(
|
||||
layout,
|
||||
selection,
|
||||
subscribeHealthChanged: _ => { },
|
||||
unsubscribeHealthChanged: _ => { },
|
||||
subscribeItemManaChanged: _ => { },
|
||||
unsubscribeItemManaChanged: _ => { },
|
||||
isHealthTarget: _ => false,
|
||||
isOwnedByPlayer: _ => false,
|
||||
name: guid => objects.Get(guid)?.GetAppropriateName(),
|
||||
healthPercent: _ => 0f,
|
||||
hasHealth: _ => false,
|
||||
stackSize: guid => (uint)(objects.Get(guid)?.StackSize ?? 0),
|
||||
sendQueryHealth: _ => { },
|
||||
manaPercent: _ => 0f,
|
||||
sendQueryItemMana: _ => { },
|
||||
datFont: null,
|
||||
splitQuantity: splitQuantity,
|
||||
subscribeObjectUpdated: h => objects.ObjectUpdated += h,
|
||||
unsubscribeObjectUpdated: h => objects.ObjectUpdated -= h,
|
||||
isVendorSplitExempt: guid =>
|
||||
vendor.VendorId != 0u
|
||||
&& objects.Get(guid) is { } vendorCandidate
|
||||
&& vendorCandidate.ContainerId == vendor.VendorId
|
||||
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
|
||||
|
||||
vendor.Apply(
|
||||
vendorGuid,
|
||||
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
|
||||
new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
scarabGuid, StackSize: -1, WeenieClassId: 5u, Name: "Lead Scarab",
|
||||
ItemType: (uint)ItemType.SpellComponents, IconId: 200u, Value: 10,
|
||||
DescStackSize: 1, MaxStackSize: 100, PluralName: "Lead Scarabs"),
|
||||
});
|
||||
Assert.Equal(100, objects.Get(scarabGuid)!.StackSize);
|
||||
|
||||
selection.Select(scarabGuid, SelectionChangeSource.Vendor);
|
||||
|
||||
var slider = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(SelectedObjectController.StackSizeSliderId));
|
||||
Assert.True(slider.Visible);
|
||||
Assert.Equal(100u, splitQuantity.Maximum);
|
||||
// Split-exempt (SpellComponents intersects the 0xDC41CB0 mask):
|
||||
// seeds at 1, ceiling stays the authored stack.
|
||||
Assert.Equal(1u, splitQuantity.GetObjectSplitSize(
|
||||
scarabGuid, scarabGuid, 100u));
|
||||
|
||||
// Retail's "{count} {plural}" toolbar label.
|
||||
var nameElement = layout.FindElement(SelectedObjectController.NameId);
|
||||
Assert.NotNull(nameElement);
|
||||
UiText nameLabel = Assert.Single(nameElement!.Children.OfType<UiText>());
|
||||
string renderedName = string.Concat(
|
||||
nameLabel.LinesProvider().Select(static line => line.Text));
|
||||
Assert.Equal("100 Lead Scarabs", renderedName);
|
||||
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -52,6 +52,77 @@ public sealed class RuntimePhysicsStateTests
|
|||
Assert.True(physics.CaptureOwnership().IsDisposed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-08 vendor-approach root fix: <c>ResolveObjectTableHost</c> is
|
||||
/// the retail <c>CObjectMaint::GetObjectA</c> seam consumed by the local
|
||||
/// player's publication-chain host. Unbound (no-window hosts) it answers
|
||||
/// exact installed hosts only; bound (the graphical host's canonical
|
||||
/// lazy resolver, installed by <c>SessionPlayerComposition</c>) it
|
||||
/// delegates every lookup — including entities that have no installed
|
||||
/// host yet, the case whose null answer left a far-use MoveToObject
|
||||
/// armed but inert. Dispose clears the binding so a retired session
|
||||
/// route cannot leak its App closure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveObjectTableHostDelegatesToTheBoundResolverAndFallsBackToInstalledHosts()
|
||||
{
|
||||
var lifetime = new RuntimeEntityObjectLifetime();
|
||||
RuntimeEntityRecord record =
|
||||
lifetime.Entities.AddActive(Spawn(0x70000001u, 1));
|
||||
EntityPhysicsHost installed = MinimalHost(record.ServerGuid);
|
||||
lifetime.Physics.InstallPhysicsHost(record, installed);
|
||||
|
||||
const uint uninstalledGuid = 0x7C95B01Cu;
|
||||
// Unbound fallback: installed hosts resolve, anything else is null.
|
||||
Assert.Same(
|
||||
installed,
|
||||
lifetime.Physics.ResolveObjectTableHost(record.ServerGuid));
|
||||
Assert.Null(lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
|
||||
|
||||
// Bound: the canonical resolver owns EVERY lookup.
|
||||
EntityPhysicsHost lazyMinimal = MinimalHost(uninstalledGuid);
|
||||
var resolvedGuids = new List<uint>();
|
||||
lifetime.Physics.BindObjectTableHostResolver(guid =>
|
||||
{
|
||||
resolvedGuids.Add(guid);
|
||||
return guid == uninstalledGuid ? lazyMinimal : null;
|
||||
});
|
||||
Assert.Same(
|
||||
lazyMinimal,
|
||||
lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
|
||||
Assert.Null(lifetime.Physics.ResolveObjectTableHost(0x70000002u));
|
||||
Assert.Equal(
|
||||
new[] { uninstalledGuid, 0x70000002u },
|
||||
resolvedGuids);
|
||||
|
||||
// Rebinding replaces (last bind wins); null clears back to the
|
||||
// exact-installed-host fallback.
|
||||
lifetime.Physics.BindObjectTableHostResolver(null);
|
||||
Assert.Null(lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
|
||||
Assert.Same(
|
||||
installed,
|
||||
lifetime.Physics.ResolveObjectTableHost(record.ServerGuid));
|
||||
|
||||
lifetime.Physics.BindObjectTableHostResolver(_ => lazyMinimal);
|
||||
lifetime.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
|
||||
}
|
||||
|
||||
private static EntityPhysicsHost MinimalHost(uint guid) => new(
|
||||
guid,
|
||||
getPosition: static () => new AcDream.Core.Physics.Position(
|
||||
0u, Vector3.Zero, Quaternion.Identity),
|
||||
getVelocity: static () => Vector3.Zero,
|
||||
getRadius: static () => 0f,
|
||||
inContact: static () => true,
|
||||
minterpMaxSpeed: static () => null,
|
||||
curTime: static () => 0d,
|
||||
physicsTimerTime: static () => 0d,
|
||||
getObjectA: static _ => null,
|
||||
handleUpdateTarget: static _ => { },
|
||||
interruptCurrentMovement: static () => { });
|
||||
|
||||
[Fact]
|
||||
public void CanonicalRecordAndPhysicsOwnerOwnRemoteComponentAndWorksets()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue