From 02b735ba4a9c1881a20aea1af467ddd12a6d05bd Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 8 Aug 2026 17:17:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(vendor):=20evidence-based=20pass=20?= =?UTF-8?q?=E2=80=94=20max-first=20stack=20ceiling;=20the=20local=20player?= =?UTF-8?q?=20resolves=20never-animated=20MoveTo=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 2 +- .../Composition/SessionPlayerComposition.cs | 14 + .../PlayerInteractionMovementSink.cs | 9 + .../SelectionInteractionController.cs | 60 +++ .../Interaction/WorldSelectionQuery.cs | 16 +- .../UI/ItemInteractionController.cs | 20 + .../UI/Layout/SelectedObjectController.cs | 22 +- .../UI/Layout/VendorUiController.cs | 21 +- src/AcDream.Core.Net/GameEventWiring.cs | 21 + src/AcDream.Core/Items/VendorDiagnostics.cs | 27 ++ src/AcDream.Core/Items/VendorSplitPolicy.cs | 72 ++- src/AcDream.Core/Items/VendorState.cs | 29 +- .../RuntimeInteractionTransactionState.cs | 44 +- ...ntimeLocalPlayerPhysicsPublicationState.cs | 21 +- .../Gameplay/VendorShopItemMaterializer.cs | 52 ++- .../Physics/RuntimePhysicsState.cs | 52 +++ .../ProductionUseApproachWiringTests.cs | 428 ++++++++++++++++++ .../Layout/SelectedObjectControllerTests.cs | 120 ++++- ...LocalPlayerPhysicsPublicationStateTests.cs | 99 ++++ .../VendorShopItemMaterializerTests.cs | 41 ++ .../Physics/RuntimePhysicsStateTests.cs | 71 +++ 21 files changed, 1139 insertions(+), 102 deletions(-) create mode 100644 src/AcDream.Core/Items/VendorDiagnostics.cs create mode 100644 tests/AcDream.App.Tests/Interaction/ProductionUseApproachWiringTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 07a0f4a0..b59e3e86 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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` | diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 160fae44..e3871e9f 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -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 diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs index f958010d..b2161cad 100644 --- a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs +++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs @@ -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. diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 78607dfd..0cd6e04e 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -173,10 +173,17 @@ internal sealed class SelectionInteractionController // Use. Equipped-child picking makes that click reachable, so the gate // ships with it. if (useImmediately && !_query.IsWieldedByPlayer(guid)) + { + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] route=dblclick-world PickAndStoreSelection guid=0x{guid:X8} enqueue=Activate"); + } EnqueueIdentityBound( RuntimeQueuedInteractionKind.Activate, guid, requireLiveEntity: true); + } } /// @@ -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(); diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 9ba0005e..9acbd662 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -567,8 +567,16 @@ internal sealed class WorldSelectionQuery /// within ACE's real UseRadius. /// 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; + } } diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index d52d0743..adba7dbf 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -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 /// 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 /// 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()) diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs index 9fa2642a..a45ccc68 100644 --- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs +++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs @@ -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. diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index e1185cd5..e818bb30 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -1401,15 +1401,18 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// this one — otherwise the CURRENT slider value via /// ItemHolder::GetObjectSplitSize (0x00586F00). /// - /// R1 gate-finding fix (2026-08-08, register AP-169 correction): the - /// ceiling this gates on is - /// — when the wire carries - /// it, else — the SAME - /// resolution uses to seed - /// (and therefore the toolbar - /// slider's own ceiling, SelectedObjectController). 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 — + /// FIRST (retail's own + /// vendor-owned quantity operand: the Buy cases at + /// pc:203996/204086 gate on pwd._maxStackSize > 1 + /// literally), falling back to + /// — the SAME resolution uses + /// to seed (and therefore the + /// toolbar slider's own ceiling, SelectedObjectController). + /// 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. /// /// private uint ResolveBuyQuantity(VendorShopItem item) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 51c20c5e..46151420 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -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); diff --git a/src/AcDream.Core/Items/VendorDiagnostics.cs b/src/AcDream.Core/Items/VendorDiagnostics.cs new file mode 100644 index 00000000..9e8d058e --- /dev/null +++ b/src/AcDream.Core/Items/VendorDiagnostics.cs @@ -0,0 +1,27 @@ +using System; + +namespace AcDream.Core.Items; + +/// +/// 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 [vendor-diag] and gated on +/// so the family is a true no-op when the flag is unset. +/// +/// +/// Read once from ACDREAM_DUMP_VENDOR=1 at process start, per Code +/// Structure Rule 5 (one static diagnostic-owner class per subsystem, no +/// per-call-site reads). +/// Lives in AcDream.Core — 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. +/// +public static class VendorDiagnostics +{ + public static bool DumpVendorEnabled { get; } = + Environment.GetEnvironmentVariable("ACDREAM_DUMP_VENDOR") == "1"; +} diff --git a/src/AcDream.Core/Items/VendorSplitPolicy.cs b/src/AcDream.Core/Items/VendorSplitPolicy.cs index 420251f1..ad2ca998 100644 --- a/src/AcDream.Core/Items/VendorSplitPolicy.cs +++ b/src/AcDream.Core/Items/VendorSplitPolicy.cs @@ -44,35 +44,57 @@ public static class VendorSplitPolicy : authoredStackSize is { } size && size > 0 ? size : 1; /// - /// 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 - /// PublicWeenieDesc::_stackSize for splitting/seeding purposes - /// (gmToolbarUI::HandleSelectionChanged, - /// pc:198688/198744/198774/198791; - /// ItemHolder::GetObjectSplitSize, pc:401465-401477). + /// 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. /// /// - /// Prefers (the wire's own - /// PublicWeenieDesc::_stackSize — 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 - /// (Vendor.LoadInventoryItem never calls wo.SetStackSize), - /// so this falls back to — 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 _stackSize 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. + /// The operand is FIRST. Retail's + /// own vendor-owned quantity sites read + /// PublicWeenieDesc::_maxStackSize DIRECTLY, never the instance + /// _stackSize: VendorItemsUI::UpdateItemsList + /// (0x004c1ea0, pc:201085-201133) displays each browse + /// row's quantity as pwd._maxStackSize (clamped by the packed + /// supply count only when that count is finite — + /// SetObjectStackSize(this, obj, min(remaining, _maxStackSize)); + /// an unlimited 0xffffffff listing shows plain + /// _maxStackSize); gmVendorUI::InqListSlotCount + /// (0x004c0c10, pc:200052) classifies list rows on + /// pwd._maxStackSize <= 1; and the Buy button cases + /// (gmVendorUI::HandleButtonClicks cases 0x100000c9 at + /// pc:203996 / 0x100000cb at pc:204086) gate the + /// stackable-buy path on pwd._maxStackSize > 1. + /// + /// + /// + /// Why desc-first was wrong (the live evidence). The R1 version + /// of this method preferred on the + /// theory that ACE never populates it for a browse row. The 2026-08-08 + /// vendor-diag live run refuted that: 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 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). + /// + /// + /// + /// remains the fallback when no + /// authored ceiling exists, then 1 (non-splittable). This helper is + /// consumed ONLY by the vendor-owned paths + /// (VendorShopItemMaterializer.ToWeenieData and + /// VendorUiController.ResolveBuyQuantity); player-inventory + /// stacks read their own wire ClientObject.StackSize and are + /// deliberately untouched by this rule. See the register, AP-169. /// /// public static int ResolveAuthoredStackSize(int? descStackSize, int? maxStackSize) => - descStackSize is { } desc && desc > 0 - ? desc - : maxStackSize is { } max && max > 0 - ? max + maxStackSize is { } max && max > 0 + ? max + : descStackSize is { } desc && desc > 0 + ? desc : 1; } diff --git a/src/AcDream.Core/Items/VendorState.cs b/src/AcDream.Core/Items/VendorState.cs index 830e729d..8f02c4d7 100644 --- a/src/AcDream.Core/Items/VendorState.cs +++ b/src/AcDream.Core/Items/VendorState.cs @@ -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 diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs index b32c9609..503e3c33 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs @@ -193,33 +193,43 @@ public sealed class RuntimeInteractionTransactionState : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(transport); sequence = 0u; + RuntimeInteractionDispatchResult verdict; if (serverGuid == 0u) { reservation?.CancelBeforeDispatch(); - return RuntimeInteractionDispatchResult.Rejected; + verdict = RuntimeInteractionDispatchResult.Rejected; } - if (!transport.IsInWorld) + else if (!transport.IsInWorld) { reservation?.CancelBeforeDispatch(); - return RuntimeInteractionDispatchResult.NotInWorld; + verdict = RuntimeInteractionDispatchResult.NotInWorld; } - if (!ownedByPlayer && !useable) + else if (!ownedByPlayer && !useable) { reservation?.CancelBeforeDispatch(); - return RuntimeInteractionDispatchResult.NotUseable; + verdict = RuntimeInteractionDispatchResult.NotUseable; } - if (!transport.TrySendUse(serverGuid, out sequence)) + else if (!transport.TrySendUse(serverGuid, out sequence)) { reservation?.CancelBeforeDispatch(); - return RuntimeInteractionDispatchResult.Rejected; + verdict = RuntimeInteractionDispatchResult.Rejected; + } + else + { + reservation?.MarkDispatched(); + _lastUseSourceId = serverGuid; + _lastUseTargetId = 0u; + IncrementRevision(); + verdict = RuntimeInteractionDispatchResult.Dispatched; } - reservation?.MarkDispatched(); - _lastUseSourceId = serverGuid; - _lastUseTargetId = 0u; - IncrementRevision(); - return RuntimeInteractionDispatchResult.Dispatched; + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] TryDispatchUse guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable} verdict={verdict} seq={sequence}"); + } + return verdict; } public bool TryDispatchTargetedUse( @@ -623,6 +633,11 @@ public sealed class RuntimeInteractionTransactionState : IDisposable if (_pendingUse is not { } current || current.ApproachToken != approachToken) { + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] TryResolveUseApproachCompletion no-match approachToken=({approachToken.ControllerLifetime},{approachToken.ApproachGeneration}) natural={natural} hasPendingUse={_pendingUse is not null}"); + } pending = default; return false; } @@ -630,6 +645,11 @@ public sealed class RuntimeInteractionTransactionState : IDisposable _pendingUse = null; pending = current; IncrementRevision(); + if (VendorDiagnostics.DumpVendorEnabled) + { + Console.WriteLine( + $"[vendor-diag] TryResolveUseApproachCompletion guid=0x{pending.ServerGuid:X8} natural={natural} accepted={natural}"); + } return natural; } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index a5380dbf..27d894f5 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -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 diff --git a/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs b/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs index 173d56eb..82568a44 100644 --- a/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs +++ b/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs @@ -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. /// /// - /// The G2 packed-supply-count fallback (retired 2026-08-08). An - /// earlier fix fell back to (the - /// packed ItemProfile "how many for sale" dword) when + /// The G2 packed-supply-count fallback (retired 2026-08-08) and the + /// R1 desc-first preference (retired the same day, live evidence). + /// An earlier fix fell back to + /// (the packed ItemProfile "how many for sale" dword) when /// DescStackSize was absent. That did not survive live 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 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 DescStackSize when - /// present (retail-faithful first, forward-compatible with any server - /// that DOES populate it), falling back to - /// — the item TYPE's authored - /// stack ceiling, which ACE DOES populate (an ordinary weenie property) - /// and which a real retail server evidently uses AS _stackSize - /// for an unlimited-supply "one full stack" browse listing. See - /// and the - /// register, AP-169. + /// usable and the bar stayed hidden. The R1 replacement preferred + /// DescStackSize 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 descStackSize=1 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 + /// PublicWeenieDesc::_maxStackSize directly + /// (VendorItemsUI::UpdateItemsList 0x004c1ea0, + /// pc:201085-201133: each row displays + /// min(remaining, _maxStackSize), plain _maxStackSize for + /// unlimited supply; gmVendorUI::InqListSlotCount + /// pc:200052 and the Buy cases pc:203996/204086 + /// gate on the same field), matching the live retail screenshot + /// ("1000 Prismatic Tapers", ceiling 1000 = the authored max stack + /// size). is + /// therefore max-first with DescStackSize as the fallback. See + /// the register, AP-169. /// /// /// Every other field doesn't carry diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 877674c5..b8cec4b4 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -1240,6 +1240,57 @@ public sealed class RuntimePhysicsState : IDisposable return false; } + /// + /// The host-installed retail CObjectMaint::GetObjectA stand-in + /// consumed by . Retail resolves ANY + /// in-world object here — every CPhysicsObj can answer + /// add_voyeur, so a moveto/sticky can target a never-animated + /// static object and still receive the immediate initial target snapshot + /// (TargetManager::AddVoyeurSendVoyeurUpdate(Ok)). This + /// state's own 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 + /// (LiveEntityMotionRuntimeController.ResolvePhysicsHost, the + /// SAME resolver every remote host's GetObjectA 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. + /// + private Func? + _objectTableHostResolver; + + /// Binds (or replaces) the canonical object-table host + /// resolver — see . Pass null to + /// clear back to the exact-installed-host fallback. + public void BindObjectTableHostResolver( + Func? resolver) + { + EnsureNotDisposed(); + _objectTableHostResolver = resolver; + } + + /// + /// Retail CObjectMaint::GetObjectA(id) 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 (). + /// + 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; } diff --git a/tests/AcDream.App.Tests/Interaction/ProductionUseApproachWiringTests.cs b/tests/AcDream.App.Tests/Interaction/ProductionUseApproachWiringTests.cs new file mode 100644 index 00000000..892dd73f --- /dev/null +++ b/tests/AcDream.App.Tests/Interaction/ProductionUseApproachWiringTests.cs @@ -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; + +/// +/// 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 natural=False only when manual input +/// cancelled it). Unlike SelectionInteractionControllerTests (fake +/// movement sink, the dead OnNaturalMoveToComplete entry point), +/// these tests drive the REAL chain end to end: +/// SelectionInteractionController.RequestUse → the REAL +/// PlayerInteractionMovementSink → the REAL +/// PlayerMovementController/MovementManager/MoveToManager +/// → the REAL EntityPhysicsHost/TargetManager voyeur +/// round-trip resolved through the REAL +/// RuntimePhysicsState.ResolveObjectTableHost seam → the REAL +/// PlayerApproachCompletionState mailbox → +/// SelectionInteractionController.DrainOutbound. +/// +/// The root cause lived in the host's getObjectA seam: bound to the +/// exact-installed-host lookup, a never-animated NPC target resolved to +/// null, TargetManager.SetTarget's add_voyeur never delivered +/// the immediate initial snapshot, and the deferred MoveToObject +/// never queued a node. The fix routes the seam through +/// RuntimePhysicsState.ResolveObjectTableHost with the graphical +/// host's canonical resolver bound +/// (SessionPlayerCompositionBindObjectTableHostResolver). +/// +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 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 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 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); + } + + /// 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. + 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(); + } + + /// + /// 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 + /// : + /// the identical drive without the bind dispatches nothing. + /// + [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); + } + + /// + /// A genuinely cancelled approach (the retail user-input chain's + /// terminal CancelMoveTo) 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. + /// + [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); + } + + /// + /// A second far use supersedes the first cleanly: the first armed use's + /// reservation releases at the supersede boundary + /// (CancelPendingApproach), the first approach's cancellation + /// no-matches the second's token in the drain, and only the second + /// dispatches on its own natural arrival. + /// + [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); + } + + /// + /// The sabotage control for + /// + /// 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. + /// + [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); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs index e701806b..a6fe0fa4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs @@ -696,8 +696,12 @@ public class SelectedObjectControllerTests /// operand is the item's authored /// (1000 for a Prismatic Taper), not any supply count. This fixture now /// matches that exact ACE-realistic shape: unlimited packed supply, no - /// DescStackSize (ACE never sets it — see the class doc above), - /// MaxStackSize=1000 from the wire. + /// DescStackSize, MaxStackSize=1000 from the wire. + /// (Second correction, 2026-08-08: the live vendor-diag wire showed ACE + /// actually serializes descStackSize=1 for every browse row, so + /// the resolver is now max-FIRST — see + /// ; + /// this desc-absent fixture resolves identically either way.) /// /// [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(); } + /// + /// 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 descStackSize=1 AND + /// stackSizeMax (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 pwd._maxStackSize directly + /// (VendorItemsUI::UpdateItemsList 0x004c1ea0), 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. + /// + [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( + 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()); + 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() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index 011b642a..b64037d4 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -2442,6 +2442,105 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests return result.ToImmutable(); } + /// + /// 2026-08-08 vendor-approach root fix (the armed-but-inert far Use): + /// the published local player's host seam getObjectA is retail's + /// CObjectMaint::GetObjectA — it must resolve ANY in-world + /// object so TargetManager.SetTarget's add_voyeur can + /// deliver the immediate initial target snapshot a deferred + /// MoveToObject needs before it queues a single node. The old + /// binding went straight to + /// RuntimePhysicsState.TryGetPhysicsHost (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 ResolvePhysicsHost stand-in, installed + /// by SessionPlayerComposition through + /// RuntimePhysicsState.BindObjectTableHostResolver) and proves + /// the SAME published moveto now receives the AddVoyeur snapshot + /// synchronously and builds its node plan. Sabotage-verified: with the + /// pre-fix getObjectA: _physics.TryGetPhysicsHost binding, + /// Phase 2 fails (the bound resolver is never consulted). + /// + [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, diff --git a/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs index a13654c8..640fc661 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs @@ -136,6 +136,11 @@ public sealed class VendorShopItemMaterializerTests /// DescStackSize, packed StackSize=-1 (unlimited), /// MaxStackSize=1000 -> ClientObject.StackSize resolves /// to 1000, not 1 and not the (nonsensical, unbounded) packed field. + /// (Second correction, 2026-08-08: the live wire showed ACE actually + /// sends descStackSize=1, so MaxStackSize is now the + /// PRIMARY operand rather than a fallback — see + /// ; + /// this desc-absent case resolves identically either way.) /// [Fact] public void Apply_UnlimitedStockNoDescStackSize_FallsBackToMaxStackSize() @@ -157,6 +162,42 @@ public sealed class VendorShopItemMaterializerTests Assert.Equal(1000, item.StackSizeMax); } + /// + /// 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 pwd._maxStackSize directly + /// (VendorItemsUI::UpdateItemsList 0x004c1ea0, + /// pc:201085-201133), so the materialized ceiling must be 100 + /// here. Sabotage-verified: restoring the desc-first preference makes + /// this resolve 1 and fail. + /// + [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); + } + /// /// Sabotage-adjacent control: the SAME unlimited-stock listing but with /// ALSO absent (neither wire diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index eba53cc6..14957dc3 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -52,6 +52,77 @@ public sealed class RuntimePhysicsStateTests Assert.True(physics.CaptureOwnership().IsDisposed); } + /// + /// 2026-08-08 vendor-approach root fix: ResolveObjectTableHost is + /// the retail CObjectMaint::GetObjectA 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 SessionPlayerComposition) 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. + /// + [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(); + 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( + () => 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() {