diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3167254f..4f33e67b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -170,7 +170,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 117 active rows (AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 118 active rows (AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -288,12 +288,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-110 | **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | -| AP-161 | **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | -| AP-162 | **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | +| AP-161 | **REVIEW CORRECTIONS 2026-08-09 (Opus review of `92ea3977`, findings F1-F13):** thirteen further fixes, mostly bug-fixes-to-already-claimed-behavior rather than new divergences, so no new AP row is filed for most of them; the exceptions are called out below. F1 ports Buy All's four retail pre-send guards (pyreal/alt-currency affordability, container-slot/item-slot capacity) — see AP-162's narrowing. F2 corrects `AddToBuyList` from upsert to retail's actual ACCUMULATE-with-5000-cap semantics and ports `RemoveFromShop`'s shop-row hide/restore as staging consumes limited vendor supply. F3 corrects `VendorSellAcceptability`'s too-valuable branch to the byte-verified bitwise-complement form (`(~(itemTypeMask >> 16)) & 4`), exempting `PromissoryNote` items. F4 wires `BF_RETAINED` end to end, RETIRING AP-164 below. F5 ports `UpdateDragOver`'s auto-switch-to-Selling-on-hover. F6 corrects sell staging to always record the FULL stack (never the live split slider) and ports `SellSingleItem`'s partial-stack refusal plus its literal amount-1 send. F7 corrects the X-close confirmation string's missing trailing question mark. F8 disposes a live confirmation dialog on session Close/Reset. F9 repaints the Buying/Selling strips' own selection highlight on every selection change, not just a staging change. F10 unstages a sell entry that leaves `ClientObjectTable` and a buy entry whose shop row is retired, the latter with retail's exact notice. F11 reorders `RequestUse`'s eligibility check ahead of `BeginApproach` so an ineligible far target no longer speculatively approaches. F13 makes Sell Item act on the global selection unconditionally, matching retail — a prior version of this port required a staged entry first. New approximations this pass introduced are filed as AP-167 (`SellSingleItem`'s non-empty-container refusal branch not ported) and AP-168 (Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container`). **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | +| AP-162 | **NARROWED 2026-08-09 (Opus review of `92ea3977`, finding F1) — the "Buy All" half of this row CLOSES.** `VendorUiController.BuyAllButtonPressed` now ports all four of retail's client-side pre-send guards (pyreal affordability `pc:204017`, alt-currency affordability `pc:204032`, container-slot capacity `pc:204053`, item-slot capacity `pc:204067`) — see `ComputeBuyTransactionValue`/`ComputeBuySlotsNeeded`/`CountPlayerContents`, each guard returning with staging fully intact and retail's own exact notice string (`"You don't have enough money"` at `0x007b57b4`, `"You must empty some slots in your backpack first"` at `0x007b5750`, both byte-recovered). The container-vs-item slot CLASSIFICATION this port uses (`ItemType.Container` instead of retail's bitfield/capacity test) is its own new, narrower approximation — filed separately as AP-168 rather than folded in here. Only `TryBuy`'s single-item Buy path (Items tab's own Buy button, and the Buying tab's "Buy Item") remains WITHOUT a client-side pre-check — the risk/oracle columns below now describe that one remaining case, not both. **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | | AP-163 | **REVIEW CORRECTION 2026-08-09 (Opus review of `97cf8738`, finding F1):** this row's ownership discipline is now COMPLETE on both halves, not just the add-time collision guard described below. The retire pass (`OnVendorTransition`'s loop over guids missing from the new `ApproachVendor` snapshot) previously deleted ANY such guid unconditionally — a plain bug, not a documented divergence, since buying a UNIQUE vendor item re-containers that SAME guid into the buyer's own pack (`Player_Commerce.cs:86-108`) BEFORE the post-buy refresh that drops it from the shop's own list arrives; the old retire pass would have stripped the just-purchased item straight back out of the buyer's inventory. **The exact rule now enforced:** each owned guid remembers the vendor id it was registered under (`Dictionary`, guid -> vendorId), and the retire pass calls `ClientObjectTable.Remove` ONLY when the live object's CURRENT `ContainerId` still equals that recorded vendor id; when it differs (or the object is already gone), the tracking entry is dropped silently and the object itself is left completely untouched — the SAME skip-not-clobber discipline the add-time collision guard below already used, now applied symmetrically on the way out. This is a bug fix, not a new divergence, and does not change this row's still-open scope: retail's actual `ClientObjMaintSystem`/`CObjectMaint` collision behavior on a guid collision remains untraced. **Filed 2026-08-09, Slice 6.1 (shop-item materialization).** `VendorShopItemMaterializer` registers each `ApproachVendor` shop item into `ClientObjectTable` keyed by its own server guid. ACE's `UniqueItemsForSale` (`Vendor.cs:34,638`) can list the EXACT guid a player last held (an item sold to this vendor keeps its original guid), so a guid collision against an existing, differently-owned `ClientObjectTable` entry is a real, if rare, possibility. No retail behavior for this exact case was traced (retail's `ClientObjMaintSystem`/`CObjectMaint` guid-keyed registration internals were not decompiled for this pass). acdream's policy is a conscious, conservative default: a guid this materializer did NOT itself add to the table on a previous cycle is treated as owned by something else and is left completely untouched — never overwritten, never later removed by this class. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`OnVendorTransition`'s collision guard) | Skip-not-clobber is the safe default absent a traced retail mechanism: silently reparenting a live entity's or another container's item into the vendor's `ContainerId` would corrupt real ownership state (equipment tracking, burden, radar) for a guid this code does not own, which is strictly worse than a single shop row's status-bar/appraisal projection staying blank. The vendor list itself is unaffected either way — `VendorUiController` reads display fields straight off `VendorShopItem`, never through `ClientObjectTable`. | If retail's actual behavior differs (e.g. it always overwrites, or a real `UniqueItemsForSale` collision is more common than assumed), the one colliding shop row's status-bar/appraisal projection stays stale/blank instead of showing the vendor listing — a narrow, single-row display gap, never a corrupted non-vendor object. Retiring this row requires tracing retail's `ClientObjMaintSystem` registration behavior on a guid collision, which was out of scope for this pass. | No direct retail citation traced this pass — `Vendor.cs:34,638` (`UniqueItemsForSale`, ACE) establishes the collision is POSSIBLE, not what retail does about it; `docs/research/2026-08-08-slice6-vendor-transactions-research.md` (task brief: "study how ACE guids vendor stock and state your collision policy with evidence") | -| AP-164 | **Filed 2026-08-09, Slice 6c (selling drag-to-sell).** `VendorProfile::InqAcceptability`'s non-sellable bitfield check (`(*(uint8_t*)((char*)arg2->_bitfield)[3] & 1) != 0`, `pc:005d1aa7`, byte 3 bit 0 — i.e. bit 24, `0x01000000` — of `PublicWeenieDesc`'s packed flags) is not ported. `VendorSellAcceptability.Evaluate` only checks the item-type mask, per-unit value, and min/max value bounds. This exact bit is not currently threaded onto `ClientObject` — no member of `PublicWeenieFlags` (`src/AcDream.Core/Items/ItemInteractionPolicy.cs`) is named at `0x01000000`, and it is unclear whether `ApproachVendor`'s wire shape even carries this flag for a player-owned pack item the way `PublicWeenieDesc` does natively. | `src/AcDream.Core/Items/VendorSellAcceptability.cs` (`Evaluate`) | The type-mask and value-bounds checks already reject most illegitimate sells; the server remains authoritative regardless — a client-side accept here is only a UX/cursor gate, not a wire-safety concern, since ACE independently validates any real Sell request. | An item explicitly marked non-sellable via this specific bit (if any real item ever authors it) would still show the accept cursor and stage on drop if its type otherwise matches the vendor's merchandise mask; the actual sale attempt's server-side fate is untraced (ACE may or may not enforce this bit either). | `VendorProfile::InqAcceptability` `pc:484768-484797`/`0x005d1a90`, bitfield test at `pc:005d1aa7`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` §Q4 | +| ~~AP-164~~ | **RETIRED 2026-08-09 (Opus review of `92ea3977`, finding F4).** `VendorProfile::InqAcceptability`'s non-sellable bitfield check (`(*(uint8_t*)((char*)arg2->_bitfield)[3] & 1) != 0`, `pc:005d1aa7`, byte 3 bit 0 — bit 24, `0x01000000`, of `PublicWeenieDesc`'s packed flags) is now ported end to end. `PublicWeenieFlags.Retained` (`src/AcDream.Core/Items/ItemInteractionPolicy.cs`) names the bit; `VendorSellAcceptability.Evaluate` takes it as a new `publicWeenieBitfield` parameter and ORs it into the SAME `WrongType` outcome the type-mask mismatch produces, matching retail's own OR'd branch exactly. The row's three original claims are each corrected by this fix, not merely superseded: the bit WAS already threaded onto `ClientObject` (`ClientObject.PublicWeenieBitfield`, `ClientObject.cs:241`, populated by `ObjectTableWiring.ToWeenieData`'s `PublicWeenieBitfield: s.ObjectDescriptionFlags` mapping) — the claim that no `PublicWeenieFlags` member was named at `0x01000000` was true only because the member had never been added, not because the underlying data was missing; and the "unclear whether `ApproachVendor`'s wire shape carries this flag for a player-owned pack item" question was moot from the start — the drag-to-sell flow always operates on the PLAYER'S OWN pack item (arrived via ordinary `CreateObject`/`EntitySpawn`, never `ApproachVendor`, which only describes the VENDOR'S stock), and that path already carried the field. Nothing is left unmodeled. | `src/AcDream.Core/Items/VendorSellAcceptability.cs` (`Evaluate`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` (`PublicWeenieFlags.Retained`) | — | — | `VendorProfile::InqAcceptability` `pc:484768-484797`/`0x005d1a90`, bitfield test at `pc:005d1aa7`; `acclient.h:6456` (`BF_RETAINED = 0x1000000`) | | AP-165 | **Filed 2026-08-09, Slice 6b (buy staging).** Retail's "Buying" tab `Buy Item`/`Clear Item` removal-amount rule (`gmVendorUI::HandleButtonClicks` cases `0x100000c9`/`0x100000cb`, `pc:203989-204010`/`204080-204094`) tests the selected item's own `pwd._maxStackSize` (the item TYPE's stack ceiling) to decide `RemoveProfileFromList`'s amount argument (`-1` full removal vs `1` partial decrement). `VendorShopItem` does not carry a `MaxStackSize` wire field (only `DescStackSize`, the item's CURRENT authored per-stack quantity, already threaded through for pricing), so `VendorUiController.BuyStagingRemovalAmount` substitutes `DescStackSize` as the stackability test instead. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`BuyStagingRemovalAmount`) | The two fields agree for every case that matters in practice — `DescStackSize <= 1` implies `MaxStackSize <= 1` — and can only disagree for a vendor stocking a single unit of an otherwise-stackable item TYPE. | In that narrow case, `Buy Item`/`Clear Item` would decrement the staged quantity by one instead of clearing the whole entry outright — a minor UI residue (a stale partial-quantity row stays staged) with no wire or money-safety consequence, since a subsequent Buy Item/Clear Item click on the same row converges it to zero anyway. | `gmVendorUI::HandleButtonClicks` cases `0x100000c9`/`0x100000cb`, `pc:203989-204010`/`204080-204094`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` §Q3 | | AP-166 | **Filed 2026-08-09, Slice 6b/6c (staging presentation).** Two cosmetic gaps in the Buying/Selling tabs: (1) their own per-row count/price text and purse-total text (`m_buyListText`/`m_buyPurseText`, D0 ids `0x100000C7`/`C8`; `m_sellListText`/`m_sellPurseText`, `0x100000D0`/`D1`) are not wired — a staged row renders as an icon cell only (matching the Items tab's own icon-strip shape), with no adjacent per-row label; (2) a successful Sell Item/Sell All/Clear Item does not port retail's cross-panel `gmVendorUI::VendorItemSetSellState` — the player's OWN inventory panel highlight that marks an item as "pending sell" is neither shown on stage nor cleared on unstage. | `src/AcDream.App/UI/Layout/VendorUiController.cs` | The underlying staging/transaction mechanics (add, remove, batched Buy All/Sell All, `InqAcceptability` gating) are fully functional without these presentation details; the Items tab's shared name/cost text pair already shows price/quantity for whichever row is currently selected (including a staged one), and the shared system-message channel already surfaces rejection feedback. | A player cannot see a NON-selected staged row's price/quantity at a glance (only the currently-selected row's name/cost renders, via the Items tab's shared text pair — there is no per-row label on the Buying/Selling strips themselves), and a pending-sell item 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); `gmVendorUI::VendorItemSetSellState` (call sites `pc:204107`/`204133`); `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 | **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) does not carry `PublicWeenieBitfield`/`ItemsCapacity`/`ContainersCapacity` the way `ClientObject` does for an ordinary `CreateObject`-sourced item, and extending it was out of scope for a pre-send guard whose failure mode is UX/latency, not correctness — the server remains authoritative and re-validates real pack-space regardless (`Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`). `ItemType.Container` is the closest already-threaded proxy. | A vendor selling a `Container`-typed item with zero authored capacity (rare/decorative) would 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) — in either case the pre-check could reject a purchase retail's own guard would have allowed, or allow one retail would have blocked, purely on the CLIENT side; the server's own capacity check is unaffected and remains the actual authority. | `gmVendorUI::InqListSlotCount` `pc:200038-200065`/`0x004c0c10`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` | | ~~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` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 4e6f9935..c42ab81b 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -231,18 +231,32 @@ internal sealed class SelectionInteractionController // TurnToObject branch, Use keeps sending immediately either way (the // existing RuntimeInteractionTransactionState.TryDispatchUse doc // comment: "consume the strict 0.2-second gate, send immediately"). - if (_query.TryGetApproach(serverGuid, out InteractionApproach approach) + // + // F11 (Slice 6b/6c review): the eligibility TryDispatchUse itself + // gates on (ownedByPlayer || useable) is computed ONCE, up front, + // and checked BEFORE BeginApproach — a prior version of this method + // called BeginApproach unconditionally whenever the target was out + // of close range, kicking off a client-predicted walk toward a + // target the dispatch below was always going to refuse anyway (a + // wasted, visually confusing approach with no possible Use at the + // end of it). Reordering does not change the dispatch itself: an + // ELIGIBLE target still sends immediately, in the same order, + // exactly as before. + bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid); + bool useable = ownedByPlayer || _query.IsUseable(serverGuid); + + if (useable + && _query.TryGetApproach(serverGuid, out InteractionApproach approach) && !approach.IsCloseRange) { _movement.BeginApproach(approach); } - bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid); RuntimeInteractionDispatchResult result = _transactions.TryDispatchUse( serverGuid, ownedByPlayer, - ownedByPlayer || _query.IsUseable(serverGuid), + useable, reservation, _transport, out uint sequence); diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 64eca190..d52d0743 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -303,12 +303,14 @@ public sealed class ItemInteractionController : IDisposable /// case 0x100000ca, pc:204011-204079) — the ONE path that /// sends a multi-item Buy in a single wire call. Rides the SAME /// one-request-at-a-time use reservation does. - /// Retail's client-side affordability/pack-capacity pre-checks - /// (pc:204017/204032/204053/204067) are deliberately NOT ported - /// here either, for the same reason 's own doc - /// comment already gives for the single-item Buy: the server is - /// authoritative either way (register AP-162, extended to this batched - /// path rather than filing a second near-duplicate row). + /// F1 (Slice 6b/6c review): retail's four client-side affordability/ + /// pack-capacity pre-checks (pc:204017/204032/204053/204067) are + /// now ported, but live in the CALLER — + /// VendorUiController.BuyAllButtonPressed — ahead of this + /// method, not inside it, since they need the staged entries' prices + /// and the player's live capacity, neither of which this method + /// otherwise touches. 's single-item path still has + /// none (register AP-162, narrowed to that one remaining case). /// public bool TryBuyAll( uint vendorGuid, diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index e909b159..800e383b 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -363,6 +363,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // X-close confirmation is already up; HandleButtonClicks' 0x100000d6 // case only opens a NEW one when this is 0 (pc:204155). private uint _closeConfirmContext; + // F5: see DragOverGlobalTimeSink's own doc comment. + private readonly DragOverGlobalTimeSink _dragOverSink; private bool _disposed; private VendorUiController( @@ -501,6 +503,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // target. UiItemList.RegisterDragHandler is the structural analogue. _sellingList?.RegisterDragHandler(this); + // F5: mount the global-time sink so a live drag hovering anywhere + // over this window auto-switches to the Selling tab — see + // DragOverGlobalTimeSink's and PollDragOver's own doc comments. + _dragOverSink = new DragOverGlobalTimeSink(PollDragOver); + _window.ContentRoot.AddChild(_dragOverSink); + // F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers // (UiMenu draws nothing without SpriteResolve — see the popup // geometry class doc above) and the vendor-authored popup geometry @@ -586,6 +594,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag _sellClearListButton.OnClick = () => _sellStaging.Clear(); _buyStaging.Changed += RebuildBuyingList; + // F2 (Slice 6b/6c review): the Items tab's own row visibility must + // track staging too — see RefreshItemsTabAvailability's doc. + _buyStaging.Changed += RefreshItemsTabAvailability; _sellStaging.Changed += RebuildSellingList; ShowTab(VendorPanelTab.Items); @@ -685,9 +696,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // non-empty (Q3's close-button finding). Optional — absent gracefully // degrades the close gate to a plain hide (see CloseButtonPressed). RetailDialogFactory? dialogs = null, - // Slice 6b/6c: InqAcceptability rejection strings + retail's two - // Buy-All affordability/capacity transient errors (deliberately not - // ported, register AP-162) share this sink. + // Slice 6b/6c: InqAcceptability rejection strings, the Buy-All + // affordability/capacity pre-send guards (F1, Slice 6b/6c review), + // and the buy/sell staging notices all share this one sink. Action? systemMessage = null) { ArgumentNullException.ThrowIfNull(layout); @@ -782,6 +793,26 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag private enum VendorPanelTab { Items, Buying, Selling } + /// + /// F5 (Slice 6b/6c review): a runtime-only, zero-size, always-invisible- + /// to-hit-testing helper that opts this window into retail's global UI + /// message 3 — gmVendorUI::ListenToGlobalMessage (0x004c0480): + /// if (arg2 == 3) gmVendorUI::UpdateDragOver(this);. This + /// controller is not itself a (it wraps + /// several), so it cannot directly implement + /// the way does — 's + /// broadcast walks the ELEMENT tree, not arbitrary controllers. Mounting + /// this tiny sink as a child of the window gives it the same periodic + /// pulse retail's own UIElementManager::UseTime delivers, without + /// adding a second per-frame plumbing path. + /// + private sealed class DragOverGlobalTimeSink : UiElement, IUiGlobalTimeListener + { + private readonly Action _onGlobalUiTime; + public DragOverGlobalTimeSink(Action onGlobalUiTime) => _onGlobalUiTime = onGlobalUiTime; + public void OnGlobalUiTime(double nowSeconds) => _onGlobalUiTime(); + } + private void ShowTab(VendorPanelTab tab) { _itemsPage.Visible = tab == VendorPanelTab.Items; @@ -841,6 +872,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag ClearContent(); ShowTab(VendorPanelTab.Items); _window.Hide(); + // F8 (Slice 6b/6c review): a live X-close confirmation + // dialog must not survive the session it was confirming + // the abandonment of — a range-triggered Close() or a + // portal/logout Reset() while the dialog is up left it + // dangling (a stale callback capturing this disposed + // controller's state) before this fix. + DismissCloseConfirmationIfOpen(); break; } } @@ -946,7 +984,20 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// field, so the toolbar status bar and slider light up for the /// auto-selected item too. /// - private void RebuildItemList() + private void RebuildItemList() => RebuildItemList(reselectFirst: true); + + /// + /// F2 (Slice 6b/6c review): for every ORIGINAL + /// call site (category rebuild — Opened/Refreshed/manual category + /// switch, retail's notify=1 case per the remarks above). + /// for the NEW staging-triggered repaint this + /// review added () — retail's + /// RemoveFromShop/DeleteItem (the function that actually + /// hides/shrinks a row as staging consumes it) never reselects to the + /// first item; only a full UpdateItemsList rebuild does that, and + /// staging a Buy does not trigger one. + /// + private void RebuildItemList(bool reselectFirst) { ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count ? _presentCategories[_selectedCategoryIndex].Mask @@ -963,6 +1014,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // Changed to correct it). uint? selectedGuid = _selection.SelectedObjectId; VendorShopItem? firstItem = null; + bool selectedStillVisible = false; using (_itemList.DeferLayout()) { @@ -972,8 +1024,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag foreach (VendorShopItem item in items) { if (((item.ItemType ?? 0u) & maskValue) == 0u) continue; + // F2 (Slice 6b/6c review): a shop item fully consumed by + // staging hides its row — port of + // VendorItemsUI::RemoveFromShop's DeleteItem branch + // (pc:202846-202852) — see AvailableShopQuantity's own + // doc comment. + if (AvailableShopQuantity(item) <= 0) continue; firstItem ??= item; + if (item.ItemGuid == selectedGuid) selectedStillVisible = true; // F5 (Slice 5.4 review): forward the icon underlay/ // overlay/effects PublicWeenieDescParser already @@ -1004,21 +1063,68 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag } } - // F6: unconditional — no survival test. Every rebuild call site - // (Opened/Refreshed via RebuildCategories, and a manual category - // switch via SelectCategory) is retail's notify=1 case; see the - // remarks above for the decomp trace. - if (firstItem is { } first) - _selection.Select(first.ItemGuid, SelectionChangeSource.Vendor); - else - _selection.Clear(SelectionChangeSource.Vendor); + if (reselectFirst) + { + // F6: unconditional — no survival test. Every rebuild call site + // (Opened/Refreshed via RebuildCategories, and a manual category + // switch via SelectCategory) is retail's notify=1 case; see the + // remarks above for the decomp trace. + if (firstItem is { } first) + _selection.Select(first.ItemGuid, SelectionChangeSource.Vendor); + else + _selection.Clear(SelectionChangeSource.Vendor); - // F7a: unconditional scroll-to-start on every rebuild (retail only - // guards on the list being non-empty; resetting an already-empty - // list's scroll is harmless). - _itemList.Scroll.SetScrollY(0); + // F7a: unconditional scroll-to-start on every rebuild (retail only + // guards on the list being non-empty; resetting an already-empty + // list's scroll is harmless). + _itemList.Scroll.SetScrollY(0); + } + else if (selectedGuid is not null && !selectedStillVisible) + { + // F2: the currently-selected row just disappeared (staging + // consumed its last unit) — matches RemoveFromShop's own + // unconditional SetSelectedObject(0, 0) on the delete path + // (pc:202848-202850), NOT a reselect-to-first. + _selection.Clear( + SelectionChangeSource.Vendor, + SelectionChangeReason.SelectedObjectRemoved); + } } + /// + /// F2 (Slice 6b/6c review): retail's RemoveFromShop + /// (0x004c3ce0) recomputes "shop remaining = ORIGINAL vendor + /// supply - the item's CURRENT total staged amount" fresh on every call + /// (pc:202844: _maxStackSize_1 = var_c - arg3, where + /// var_c is read fresh from the untouched shopItemProfileList + /// and arg3 is AddToBuyList's own running accumulated + /// total) rather than incrementally decrementing a mutable counter. + /// This mirrors that shape exactly: + /// (the untouched ApproachVendor snapshot) combined with + /// 's current total, recomputed on every call + /// — so un-staging automatically restores a hidden/shrunk row with no + /// separate "restore" code path. for + /// unlimited supply ( == -1, + /// retail's ItemProfile sign-extended unlimited sentinel) — + /// retail's own var_c != 0xffffffff guard skips the whole + /// decrement/delete branch for unlimited stock. + /// + private int AvailableShopQuantity(VendorShopItem item) + { + if (item.StackSize < 0) + return int.MaxValue; + int staged = _buyStaging.TryGet(item.ItemGuid, out VendorStagingEntry entry) ? entry.Quantity : 0; + return item.StackSize - staged; + } + + /// + /// F2 (Slice 6b/6c review): repaints the Items tab's row visibility + /// whenever changes, WITHOUT the + /// reselect-to-first behavior a full category rebuild performs — see + /// 's reselectFirst doc. + /// + private void RefreshItemsTabAvailability() => RebuildItemList(reselectFirst: false); + /// /// Port of retail row selection display — /// VendorItemsUI::UpdateItemsUI (0x004C38E0, @@ -1115,6 +1221,35 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag { _ = transition; RefreshSelectionDisplay(); + // F9 (Slice 6b/6c review): a click on an already-staged Buying/ + // Selling row calls SelectionState.Select the same way an Items-tab + // row does (RebuildBuyingList/RebuildSellingList's own Clicked + // handlers), but those two lists are only ever REBUILT when their + // OWN staging list changes -- a pure selection change (no staging + // mutation) never repainted their cell.Selected flags, so the + // highlight silently failed to move onto a staged row. Update both + // strips' highlight in place on every selection transition, no + // matter which panel originated it -- mirrors RefreshSelectionDisplay's + // own "react to ANY global selection change" shape. + RefreshStagingSelectionHighlight(); + } + + /// See 's F9 note. + private void RefreshStagingSelectionHighlight() + { + uint? selected = _selection.SelectedObjectId; + SetHighlight(_buyingList, selected); + SetHighlight(_sellingList, selected); + + static void SetHighlight(UiItemList? list, uint? selectedGuid) + { + if (list is null) return; + for (int i = 0; i < list.GetNumUIItems(); i++) + { + if (list.GetItem(i) is { } cell) + cell.Selected = cell.ItemId == selectedGuid; + } + } } /// @@ -1158,6 +1293,33 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// (Slice 6.1) is therefore what actually drives "vendor session close /// clears a vendor-owned selection." /// + /// + /// F10 (Slice 6b/6c review): retail's two unstage-on-dispossession + /// sites, both reachable through this SAME + /// notification in this architecture. + /// + /// Sell sidegmVendorUI::RecvNotice_ServerSaysMoveItem + /// (0x004c44a0): a staged SELL item silently unstages once + /// ACCWeenieObject::IsOwnedByPlayer goes false for it (no notice + /// shown). The closest reachable proxy here is "the item left + /// entirely" — a strictly narrower + /// trigger than retail's "moved to ANY non-player container," but the + /// only one an item leaving the table for real (destroyed, traded away + /// and never re-registered, sold through a different path) reaches. + /// + /// + /// Buy sideVendorItemsUI's shop-list-removal notice + /// (0x004c4246, inside gmVendorUI::HandleMousePresses, + /// pc:203165: "Removing %s from shopping list"). A staged + /// BUY guid that drops out of the vendor's CURRENT stock (sold out by + /// someone else, or a different vendor superseded this session) is + /// retired from by + /// VendorShopItemMaterializer's own retire pass on every + /// Opened/Refreshed/Closed/Reset transition — which fires this SAME + /// event, giving one unified site for both retail sources instead of a + /// separate "diff the vendor's item list" listener. + /// + /// private void OnObjectRemoved(ClientObject item) { if (_selection.SelectedObjectId == item.ObjectId) @@ -1166,6 +1328,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag SelectionChangeSource.Vendor, SelectionChangeReason.SelectedObjectRemoved); } + + // Sell side: silent, matching retail's own RecvNotice_ServerSaysMoveItem. + _sellStaging.Remove(item.ObjectId, -1); + + // Buy side: retail's exact "Removing %s from shopping list" notice. + if (_buyStaging.Remove(item.ObjectId, -1)) + { + string name = string.IsNullOrWhiteSpace(item.Name) ? "that item" : item.Name; + _systemMessage?.Invoke($"Removing {name} from shopping list"); + } } /// @@ -1350,6 +1522,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// SAME quantity computation the display and Buy button already share /// (F2) — into the "Buying" tab's list. Sends NOTHING to the server /// (VendorItemsUI::AddToBuyList is purely client-local). + /// F2 (Slice 6b/6c review): re-adding an already-staged item ACCUMULATES + /// rather than overwrites, and retail's 5000-unit cap on that + /// accumulate shows and + /// leaves the entry unchanged — see . /// private void AddSelectedToBuyList() { @@ -1357,7 +1533,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return; uint quantity = ResolveBuyQuantity(shopItem); - _buyStaging.Add(shopItem.ItemGuid, (int)quantity); + if (_buyStaging.Add(shopItem.ItemGuid, (int)quantity) == VendorStagingAddOutcome.Capped) + _systemMessage?.Invoke(VendorStagingList.TooMuchMessage); } /// @@ -1402,16 +1579,64 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag } } + /// + /// Retail's exact affordability-failure notice — read from the + /// decompiled binary's data segment at 0x007b57b4 (BuyAllButtonPressed's + /// two guards below share this SAME string; see the class doc). + /// + private const string NotEnoughMoneyMessage = "You don't have enough money"; + + /// + /// Retail's exact capacity-failure notice — read from the decompiled + /// binary's data segment at 0x007b5750 (BuyAllButtonPressed's + /// two capacity guards below share this SAME string; see the class doc). + /// + private const string NotEnoughRoomMessage = "You must empty some slots in your backpack first"; + /// /// Slice 6b: "Buying" tab's "Buy All" — retail case 0x100000ca - /// (pc:204011-204079). Sends every staged entry as ONE batched - /// Buy (). Retail's - /// client-side affordability/pack-capacity pre-checks - /// (pc:204017/204032/204053/204067) are deliberately NOT ported — - /// see TryBuyAll's own doc comment (register AP-162, extended - /// rather than duplicated). On a successful DISPATCH the whole staged - /// list is flushed UNCONDITIONALLY and immediately, matching retail's - /// literal order: SendShopEvent(...) then + /// (pc:204011-204079, 0x004c5388). Sends every staged + /// entry as ONE batched Buy (). + /// + /// F1 (Slice 6b/6c review): retail's FOUR client-side pre-send guards + /// are ported here, in order, each returning with staging fully + /// intact on failure (amends AP-162's "no client-side pre-check" + /// claim — see the register): + /// + /// + /// pyreal affordability — transaction total vs. purse + /// (pc:204017: m_transactionValue <= m_totalValue). + /// alt-currency affordability — vs. held trade currency minus + /// m_last_sale (pc:204032). This session tracks no + /// m_last_sale credit yet (see the register's AP-161 residual), + /// so this uses the vendor's raw held count, retail's own + /// m_last_sale == 0 case. + /// container-slot capacity (pc:204053: + /// containerSlotsNeeded > player.ContainersCapacity - containersUsed). + /// item-slot capacity (pc:204067: the same shape for + /// item slots). + /// + /// Both affordability guards share ; + /// both capacity guards share — + /// retail's own two distinct StringInfo literal sites collapse + /// to exactly these two strings (pc:204020/204034 both + /// reference 0x007b57b4; pc:204056/204068 both + /// reference 0x007b5750 via the shared label_4c5509). + /// + /// Container-vs-item slot classification (register AP-168). + /// Retail's own split tests a bitfield bit this codebase does not + /// currently thread onto + /// (gmVendorUI::InqListSlotCount, pc:200038-200065) — this + /// port approximates "is this shop item a container" with + /// instead, correct for the ordinary + /// case (a real backpack/pouch DOES carry that type bit) but not + /// byte-identical for the theoretical case of a non-Container-typed + /// item that still authors nonzero pack/side capacities. See the + /// register. + /// + /// On a successful DISPATCH the whole staged list is flushed + /// UNCONDITIONALLY and immediately, matching retail's literal order: + /// SendShopEvent(...) then /// PackableList::Flush(&m_buyList) (pc:204075-204076) — /// the clear happens right after the send, not gated on the eventual /// server response/UseDone. @@ -1425,10 +1650,105 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag foreach (VendorStagingEntry entry in _buyStaging.Entries) items.Add((entry.Quantity, entry.ItemGuid)); - if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, _vendor.Profile.AlternateCurrencyWcid)) + VendorShopProfile profile = _vendor.Profile; + int transactionValue = ComputeBuyTransactionValue(); + + // Guards 1/2: pyreal vs. alt-currency affordability. + if (profile.AlternateCurrencyWcid == 0u) + { + int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; + if (transactionValue > playerTotal) + { + _systemMessage?.Invoke(NotEnoughMoneyMessage); + return; + } + } + else if (transactionValue > (int)profile.AlternateCurrencyAmount) + { + _systemMessage?.Invoke(NotEnoughMoneyMessage); + return; + } + + // Guards 3/4: container-slot then item-slot capacity. + (int itemSlotsNeeded, int containerSlotsNeeded) = ComputeBuySlotsNeeded(items); + ClientObject? player = _objects.Get(_playerGuid()); + (int itemsUsed, int containersUsed) = CountPlayerContents(); + + int freeContainerSlots = (player?.ContainersCapacity ?? 0) - containersUsed; + if (containerSlotsNeeded > freeContainerSlots) + { + _systemMessage?.Invoke(NotEnoughRoomMessage); + return; + } + int freeItemSlots = (player?.ItemsCapacity ?? 0) - itemsUsed; + if (itemSlotsNeeded > freeItemSlots) + { + _systemMessage?.Invoke(NotEnoughRoomMessage); + return; + } + + if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid)) _buyStaging.Clear(); } + /// F1: the SAME per-row price formula shows, summed over every staged entry. + private int ComputeBuyTransactionValue() + { + VendorShopProfile profile = _vendor.Profile; + int total = 0; + foreach (VendorStagingEntry entry in _buyStaging.Entries) + { + if (!TryFindShopItem(entry.ItemGuid, out VendorShopItem item)) + continue; + int perUnit = VendorPricing.PerUnitValue(item.Value ?? 0, item.DescStackSize); + total += VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, entry.Quantity); + } + return total; + } + + /// + /// F1: port of gmVendorUI::InqListSlotCount (pc:200038-200065, + /// 0x004c0c10) — see 's own doc + /// comment for the container-classification approximation. + /// + private (int ItemSlots, int ContainerSlots) ComputeBuySlotsNeeded( + IReadOnlyList<(int Amount, uint ItemGuid)> items) + { + int itemSlots = 0, containerSlots = 0; + foreach ((int amount, uint guid) in items) + { + if (!TryFindShopItem(guid, out VendorShopItem item)) + continue; + bool isContainer = ((item.ItemType ?? 0u) & (uint)ItemType.Container) != 0u; + bool stackable = (item.DescStackSize ?? 1) > 1; + if (stackable) + { + if (isContainer) containerSlots += 1; else itemSlots += 1; + } + else + { + if (isContainer) containerSlots += amount; else itemSlots += amount; + } + } + return (itemSlots, containerSlots); + } + + /// F1: the player's CURRENT occupied item/container slot counts. + private (int Items, int Containers) CountPlayerContents() + { + int items = 0, containers = 0; + foreach (uint guid in _objects.GetContents(_playerGuid())) + { + ClientObject? obj = _objects.Get(guid); + bool isContainer = obj is not null + && (obj.ItemsCapacity > 0 + || obj.ContainersCapacity > 0 + || (obj.Type & ItemType.Container) != 0); + if (isContainer) containers++; else items++; + } + return (items, containers); + } + /// /// Slice 6b: "Buying" tab's "Clear Item" — retail case 0x100000cb /// (pc:204080-204094). The EXACT SAME removal call "Buy Item" @@ -1446,25 +1766,74 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag _buyStaging.Remove(guid, amount); } + /// + /// Retail's exact refusal for a partial-stack Sell Item attempt, + /// read from the decompiled binary's data segment at + /// 0x007b57ec (gmVendorUI::SellSingleItem, + /// pc:201860-201864). + /// + private const string CannotSellPartialStackMessage = "Cannot sell part of a stack"; + /// /// Slice 6c: "Selling" tab's "Sell Item" — retail case 0x100000d2 - /// (pc:204101-204112). Sells the globally-selected staged item - /// immediately (a one-entry - /// list using the entry's OWN staged quantity — Sell has no separate - /// global-slider read the way Buy does), then on a successful dispatch - /// removes the ENTIRE staged entry unconditionally — retail always - /// passes 0xffffffff here (pc:204108), unlike the Buy - /// side's stackable-conditional amount. + /// (pc:204101-204112), calling gmVendorUI::SellSingleItem + /// (pc:201808-201881, 0x004c2b40). + /// + /// F13 (Slice 6b/6c review): retail reads ACCWeenieObject::selectedID + /// UNCONDITIONALLY — the GLOBAL selection, with NO "is this guid + /// actually staged" requirement at all. A prior version of this port + /// required a matching entry first; that + /// gate does not exist in retail (you can Sell Item something you + /// never dragged onto the Selling tab, exactly like the Items tab's + /// own single-item Buy button operates on the selection with no + /// staging requirement either). + /// + /// + /// F6 (Slice 6b/6c review, byte-verified): SellSingleItem + /// refuses a PARTIAL stack — the selected item's own split slider must + /// show the FULL stack (or the item must be non-stackable), else it + /// shows and sends NOTHING + /// (pc:201833-201864: _stackSize<=1 || splitSize>=maxSplitSize + /// gates the send). On success it sends amount 1 LITERALLY + /// (var_9c = 1, pc:201838) — not the stack size — matching + /// retail's own send exactly rather than . + /// This method does NOT re-run 's + /// ownership/type/value gate — retail's own SellSingleItem + /// doesn't either at this call site (that gate is drag-time only, + /// VendorSellUI::DragItemAcceptable); the server remains + /// authoritative for a selection that was never legitimately + /// draggable. + /// + /// + /// Retail's own non-empty-container refusal branch inside + /// SellSingleItem (pc:201818-201829, a container-type + /// item with contents currently blocks a Sell Item attempt on the + /// CONTAINER itself with a distinct message) is NOT ported here — see + /// the register. + /// + /// On a successful dispatch the item's own staged entry (if any) is + /// still removed unconditionally, mirroring retail's own + /// RemoveProfileFromList(&m_sellList, selectedID, 0xffffffff) + /// (pc:204108), which runs regardless of whether the sold item + /// was ever actually staged. /// private void SellItemButtonPressed() { - if (_selection.SelectedObjectId is not { } guid - || !_sellStaging.TryGet(guid, out VendorStagingEntry entry)) - { + if (_selection.SelectedObjectId is not { } guid || _objects.Get(guid) is not { } item) return; + + uint fullStack = (uint)Math.Max(1, item.StackSize); + if (fullStack > 1) + { + uint live = _splitQuantity.GetObjectSplitSize(guid, guid, fullStack); + if (live < fullStack) + { + _systemMessage?.Invoke(CannotSellPartialStackMessage); + return; + } } - if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (entry.Quantity, guid) })) + if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (1, guid) })) _sellStaging.Remove(guid, -1); } @@ -1617,6 +1986,44 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag : ItemDragAcceptance.Reject; } + /// + /// F5 (Slice 6b/6c review): port of gmVendorUI::UpdateDragOver + /// (0x004c03b0, pc:199542-199553), driven by + /// 's periodic pulse. While the + /// window is visible, the Selling page is NOT already the open one, and + /// a drag is currently live anywhere in the whole UI (retail's + /// UIElementManager::s_pInstance->m_dragElement != 0, here + /// ), retail opens the Selling tab the + /// instant the pointer enters the WINDOW'S bounds — not any specific + /// list — making a reachable drop target + /// without the player manually clicking the tab first. + /// + /// Citation correction. Retail's own guard token is + /// m_OpenPageToken != 0x100000cd — the review that flagged this + /// finding described 0x100000cd as "the Buying page," but this + /// controller's own constant is + /// 0x100000CD, not + /// (0x100000C4). The guard is "don't reopen the tab you're + /// already on," checked against the SELLING page specifically — + /// 's own Visible flag is the exact + /// analogue. + /// + /// + private void PollDragOver() + { + if (_sellingPage.Visible) return; + if (!_window.IsVisible) return; + + UiRoot? root = _itemsPage.FindRoot(); + if (root?.DragSource is null) return; + + System.Numerics.Vector2 pos = _window.OuterFrame.ScreenPosition; + float x0 = pos.X, y0 = pos.Y; + float x1 = x0 + _window.OuterFrame.Width, y1 = y0 + _window.OuterFrame.Height; + if (root.MouseX > x0 && root.MouseX < x1 && root.MouseY > y0 && root.MouseY < y1) + ShowTab(VendorPanelTab.Selling); + } + /// /// Port of VendorSellUI::AcceptDragObject (pc:203866-203905, /// the non-silent release path that calls DragItemAcceptable with @@ -1652,19 +2059,21 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// fed from the dragged item's own state and /// the open vendor's merchandise terms. /// is the staged quantity a successful drop - /// would use: 1 for a non-stack item, else the LIVE split-quantity - /// slider value for the dragged item — the SAME read - /// ExternalContainerController.HandleDropRelease already uses - /// (StackSplitQuantityState.GetObjectSplitSize only returns the - /// slider value for the CURRENTLY selected item; both - /// InventoryController and PaperdollController already - /// select the dragged item on LIFT — ItemList_BeginDrag @ - /// 0x004E32D0 — so by the time a drop reaches here the dragged item - /// is already the global selection in the realistic flow). The Slice - /// 6b/6c research doc's Q4 section flags this exact quantity source as - /// an inferred analogy to the Buying tab's AddToBuyList, not a - /// byte-verified citation for the Selling side specifically — see - /// VendorStagingList.Add's own doc comment. + /// would use. + /// + /// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's + /// FULL current stack — retail's VendorSellUI::AddItemToSell + /// (pc:203546-203567) stages via gmVendorUI::AddItem(..., + /// itemGuid, -1, ...), a LITERAL -1 "full stack" sentinel + /// argument, never a slider read. A prior version of this port read the + /// LIVE split-quantity slider here instead (the Slice 6b/6c research + /// doc's Q4 section had flagged this exact source as an unverified + /// inferred analogy to the Buying tab's AddToBuyList) — that + /// inference is now known WRONG: Sell staging has no partial-quantity + /// feature in retail at all, unlike Buy. See + /// VendorStagingList.Add's own doc comment for the Buy side's + /// (genuinely slider-driven) contrast. + /// /// private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity) { @@ -1683,15 +2092,11 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag perUnitValue, _vendor.Profile.MerchandiseItemTypes, _vendor.Profile.MerchandiseMinValue, - _vendor.Profile.MerchandiseMaxValue); + _vendor.Profile.MerchandiseMaxValue, + item.PublicWeenieBitfield ?? 0u); if (rejection == VendorSellRejection.None) - { - uint fullStack = (uint)Math.Max(1, item.StackSize); - quantity = fullStack > 1 - ? (int)_splitQuantity.GetObjectSplitSize(itemGuid, _selection.SelectedObjectId ?? 0u, fullStack) - : 1; - } + quantity = (int)Math.Max(1, item.StackSize); return rejection; } @@ -1737,8 +2142,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// which preserves the player's category selection) instead of a full /// from-scratch reopen. /// + /// + /// F7 (Slice 6b/6c review, byte-verified): the decompiled pseudo-C's + /// declared string length (0x53 wchar16) truncates the literal + /// mid-sentence, but the raw bytes immediately following it + /// (0x007b5c7e) are 3f 00 — UTF-16LE for '?' — + /// before the null terminator. Retail's data segment carries a trailing + /// question mark this port previously dropped. + /// private const string CloseConfirmationMessage = - "You have not completed all transactions. Are you sure you want to leave this vendor"; + "You have not completed all transactions. Are you sure you want to leave this vendor?"; private void CloseButtonPressed() { @@ -1763,6 +2176,20 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag }); } + /// + /// F8 (Slice 6b/6c review): shared by and the + /// session Closed/Reset arm of — a live + /// X-close confirmation dialog must not outlive the controller or the + /// vendor session it was asking about. + /// + private void DismissCloseConfirmationIfOpen() + { + if (_closeConfirmContext == 0u) + return; + _dialogs?.CloseDialog(_closeConfirmContext); + _closeConfirmContext = 0u; + } + private void ClearContent() { _presentCategories.Clear(); @@ -1830,12 +2257,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag _itemInteraction.StateChanged -= OnInteractionStateChanged; _splitQuantity.Changed -= OnSplitQuantityChanged; _buyStaging.Changed -= RebuildBuyingList; + _buyStaging.Changed -= RefreshItemsTabAvailability; _sellStaging.Changed -= RebuildSellingList; - if (_closeConfirmContext != 0u) - { - _dialogs?.CloseDialog(_closeConfirmContext); - _closeConfirmContext = 0u; - } + DismissCloseConfirmationIfOpen(); + _dragOverSink.Parent?.RemoveChild(_dragOverSink); RetailTabBinding.SetClick(_itemsTab, null); RetailTabBinding.SetClick(_buyingTab, null); RetailTabBinding.SetClick(_sellingTab, null); diff --git a/src/AcDream.Core/Items/ItemInteractionPolicy.cs b/src/AcDream.Core/Items/ItemInteractionPolicy.cs index 4de5cd70..f1bc7210 100644 --- a/src/AcDream.Core/Items/ItemInteractionPolicy.cs +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -26,6 +26,13 @@ public enum PublicWeenieFlags : uint Healer = 0x00010000, Lockpick = 0x00020000, RequiresPackSlot = 0x00800000, + /// + /// F4 (Slice 6b/6c review): BF_RETAINED, the "unsellable" bit + /// VendorProfile::InqAcceptability tests (pc:005d1aa7, + /// byte 3 bit 0 of PublicWeenieDesc::_bitfield). See + /// . + /// + Retained = 0x01000000, VolatileRare = 0x10000000, WieldOnUse = 0x20000000, WieldLeft = 0x40000000, diff --git a/src/AcDream.Core/Items/VendorSellAcceptability.cs b/src/AcDream.Core/Items/VendorSellAcceptability.cs index 467d48a2..58304a96 100644 --- a/src/AcDream.Core/Items/VendorSellAcceptability.cs +++ b/src/AcDream.Core/Items/VendorSellAcceptability.cs @@ -18,8 +18,28 @@ public enum VendorSellRejection /// /// InqAcceptability returned the raw item_types bitmask /// (the type-mismatch/non-sellable-bit branch, pc:005d1af8) — - /// the generic case in practice, since a genuine bitmask is almost never - /// literally 1-4 (Slice 6b/6c research doc's open question #3). + /// mapped here to the generic "You cannot sell that here" message the + /// same way retail's DragItemAcceptable switch falls through for + /// any return value outside its four named cases (1-4). + /// + /// F12 (Slice 6b/6c review) — SOFTENED CLAIM: an earlier version of this + /// doc comment asserted a genuine bitmask is "almost never literally + /// 1-4." That is not true in general: + /// MeleeWeapon/Armor/Clothing are the single bits + /// 1/2/4, and a real specialist vendor's own + /// MerchandiseItemTypes could legitimately be exactly one of + /// them (a weapon-only, armor-only, or clothing-only shop is an + /// ordinary AC vendor archetype). For such a vendor, retail's raw + /// item_types return WOULD collide with DragItemAcceptable's + /// own named cases 1/2/4 ("cannot be sold here" / "has no value" / + /// "too valuable") — a genuine type mismatch would show the WRONG + /// retail message, not the generic one. This class does not reproduce + /// that collision (it always returns the semantic + /// case, never a raw integer another case could + /// alias), so acdream's own message is unaffected either way; this note + /// only corrects the doc's claim about how often retail's OWN collision + /// is reachable, in case a byte-exact reproduction is ever wanted. + /// /// WrongType, @@ -40,6 +60,19 @@ public enum VendorSellRejection /// /// InqAcceptability's "too valuable" branch (pc:005d1add): /// max_value != -1 && value > max_value. + /// + /// F3 (Slice 6b/6c review, byte-verified at 0x005d1add): the + /// actual x86 at that return site is mov eax,edi; shr eax,0x10; + /// not eax; and eax,4; ret — i.e. (~(itemTypeMask >> 16)) & 4, + /// a BITWISE complement (the decompiled pseudo-C's ! is + /// misleading — it is not a logical NOT). ItemType.PromissoryNote + /// (0x00040000, bit 18) sits exactly at bit 2 of + /// itemTypeMask >> 16, so a trade note above the vendor's + /// max value returns 0 (fully , exempt) instead of 4 + /// () — and the ret at that exact + /// address means the min-value check below is skipped entirely for a + /// trade note, not merely the max-value rejection. + /// /// TooValuable, @@ -84,6 +117,18 @@ public static class VendorSellAcceptability /// The vendor's VendorShopProfile.MerchandiseMaxValue — /// means retail's unset -1. /// + /// + /// F4 (Slice 6b/6c review): the dragged item's own + /// PublicWeenieDesc::_bitfield (, + /// populated on every ordinary CreateObject — including the + /// player's own pack items, the only things ever dragged here). Tested + /// against + /// (BF_RETAINED = 0x01000000, acclient.h:6456) — retail's + /// InqAcceptability ORs this bit into the SAME type-mismatch + /// branch (byte 3 bit 0 of the bitfield, pc:005d1aa7), so it + /// folds into the same + /// outcome, not a distinct rejection reason. + /// public static VendorSellRejection Evaluate( bool ownedByPlayer, int containedItemCount, @@ -91,21 +136,34 @@ public static class VendorSellAcceptability int perUnitValue, uint merchandiseItemTypes, uint merchandiseMinValue, - uint merchandiseMaxValue) + uint merchandiseMaxValue, + uint publicWeenieBitfield = 0u) { if (!ownedByPlayer) return VendorSellRejection.NotOwnedByPlayer; if (containedItemCount > 0) return VendorSellRejection.None; - if ((itemTypeMask & merchandiseItemTypes) == 0u) + // F4: InqAcceptability's first check ORs the type-mask mismatch + // with the BF_RETAINED bit (pc:005d1aa7) -- both branches return + // the SAME raw item_types value, so both fold into WrongType here. + bool retained = (publicWeenieBitfield & (uint)PublicWeenieFlags.Retained) != 0u; + if ((itemTypeMask & merchandiseItemTypes) == 0u || retained) return VendorSellRejection.WrongType; if (perUnitValue == 0) return VendorSellRejection.NoValue; if (merchandiseMaxValue != NoLimit && perUnitValue > merchandiseMaxValue) - return VendorSellRejection.TooValuable; + { + // F3 (byte-verified at 0x005d1add) -- see TooValuable's own doc + // comment: a PromissoryNote (trade note) is EXEMPT from the + // max-value rejection (and, by the disassembly's early ret, + // from the min-value check too), not just capped differently. + return (itemTypeMask & (uint)ItemType.PromissoryNote) != 0u + ? VendorSellRejection.None + : VendorSellRejection.TooValuable; + } if (merchandiseMinValue != NoLimit && perUnitValue < merchandiseMinValue) return VendorSellRejection.TooCheap; diff --git a/src/AcDream.Core/Items/VendorStagingList.cs b/src/AcDream.Core/Items/VendorStagingList.cs index e327c5fd..d89f102f 100644 --- a/src/AcDream.Core/Items/VendorStagingList.cs +++ b/src/AcDream.Core/Items/VendorStagingList.cs @@ -16,8 +16,38 @@ public readonly record struct VendorStagingEntry(uint ItemGuid, int Quantity); /// 6b/6c research doc's open question #2); this is the one generic list both /// tab controllers own an instance of, rather than two near-duplicate types. /// +/// Outcome of — see its doc comment. +public enum VendorStagingAddOutcome +{ + /// Staged: a new entry was appended, or an existing one accumulated. + Added, + /// + /// Retail's 5000-unit cap (0x1388) on an ACCUMULATED total would + /// be exceeded — rejected, the staged entry is unchanged. Show + /// . + /// + Capped, + /// Defensive no-op (zero guid or non-positive quantity) — never a retail-modeled path; do not message. + Ignored, +} + public sealed class VendorStagingList { + /// + /// Retail's cap on a staged entry's ACCUMULATED total — 0x1388 + /// (5000), VendorItemsUI::AddToBuyList's comparison + /// (pc:202936, 0x004c3e73). + /// + public const int MaxStagedQuantity = 0x1388; + + /// + /// Retail's exact over-cap notice, read from the decompiled binary's + /// data segment at 0x007b59d8 (VendorItemsUI::AddToBuyList, + /// pc:202938-202949). + /// + public const string TooMuchMessage = + "I can't possibly sell you that much! Please be a little more reasonable."; + private readonly List _entries = new(); public IReadOnlyList Entries => _entries; @@ -27,28 +57,40 @@ public sealed class VendorStagingList /// /// Port of the Buying tab's VendorItemsUI::AddToBuyList insertion - /// (Slice 6b/6c research doc §Q3): stages - /// units of . Re-adding an already-staged guid - /// (e.g. pressing "Add to List" again after moving the slider) UPSERTS - /// the entry to the new quantity rather than appending a duplicate row — - /// retail's own RemoveProfileFromList looks up an entry BY GUID - /// (a single match), which only holds if AddToBuyList never - /// produces two rows for the same guid; the decomp excerpt available to - /// this port does not show the insert side of that invariant directly, - /// so this is a deliberate, documented inference from the removal side's - /// single-match contract, not a byte-verified citation. + /// (pc:202884-202989, 0x004c3dc0 — read in full for the + /// Slice 6b/6c review; the insert side is byte-verified below, not + /// inferred). Re-adding an already-staged guid (e.g. pressing "Add to + /// List" again after moving the slider) ACCUMULATES + /// onto the entry's EXISTING staged amount + /// (eax_3 = arg3 + var_9c, pc:202934) — NOT an upsert/ + /// overwrite, the shape a prior version of this port used before this + /// review. A brand-new entry (no existing match) is inserted at + /// directly with NO cap check + /// (label_4c3e20, pc:202895-202923) — the 5000-unit cap + /// only guards the ACCUMULATE branch. Exceeding the cap on an + /// accumulate shows and leaves the entry + /// COMPLETELY UNCHANGED (no partial accumulate, no shop-row effect — + /// pc:202936-202951 jumps straight past both). /// - public void Add(uint itemGuid, int quantity) + public VendorStagingAddOutcome Add(uint itemGuid, int quantity) { if (itemGuid == 0u || quantity <= 0) - return; + return VendorStagingAddOutcome.Ignored; int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid); if (index >= 0) - _entries[index] = new VendorStagingEntry(itemGuid, quantity); + { + int total = _entries[index].Quantity + quantity; + if (total > MaxStagedQuantity) + return VendorStagingAddOutcome.Capped; + _entries[index] = new VendorStagingEntry(itemGuid, total); + } else + { _entries.Add(new VendorStagingEntry(itemGuid, quantity)); + } Changed?.Invoke(); + return VendorStagingAddOutcome.Added; } /// diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index fb84420b..e4d9d9a7 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -411,6 +411,31 @@ public sealed class SelectionInteractionControllerTests Assert.Empty(h.Transport.Uses); } + /// + /// F11 (Slice 6b/6c review): a FAR target the eligibility gate will + /// refuse (not owned by the player, not useable) must never kick off a + /// speculative local approach at all — the prior RequestUse + /// ordering called BeginApproach before the eligibility check, + /// so an out-of-range unusable target still walked the player toward + /// it even though the dispatch immediately below was always going to + /// reject. The existing + /// test never configured an approach at all (TryGetApproach + /// returns false unconditionally), so it could not have caught this — + /// this test explicitly combines "far" with "rejected." + /// + [Fact] + public void FarUnusableTargetIsRejectedWithoutApproaching() + { + var h = new Harness(); + h.Query.Useable = false; + h.SetApproach(closeRange: false); + + h.Controller.SendUse(Target); + + Assert.Empty(h.Movement.Approaches); + Assert.Empty(h.Transport.Uses); + } + [Fact] public void SynchronousMovementCallbackCannotDuplicateWorldUse() { diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index de8fe171..b8434874 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -207,6 +207,21 @@ public sealed class VendorUiControllerTests public Harness() { + // F1 (Slice 6b/6c review): a realistic player pack shape (102 + // main-pack item slots, 7 side-pack/container slots — the + // conventional retail AC main-pack capacity) so the NEW Buy All + // capacity pre-checks have real room to work with; a stub + // ClientObject's default ItemsCapacity/ContainersCapacity=0 + // would reject every Buy All. AddOrUpdate FIRST, then + // UpsertProperties SECOND — UpsertProperties mutates an + // EXISTING entry in place rather than replacing it wholesale. + Objects.AddOrUpdate(new ClientObject + { + ObjectId = PlayerGuid, + Type = ItemType.Creature, + ItemsCapacity = 102, + ContainersCapacity = 7, + }); var bundle = new PropertyBundle(); bundle.Ints[(uint)PropertyInt.CoinValue] = DefaultPlayerCoinValue; Objects.UpsertProperties(PlayerGuid, bundle); @@ -1385,6 +1400,116 @@ public sealed class VendorUiControllerTests Assert.Equal(0, h.BuyingList.GetNumUIItems()); } + /// + /// F2 (Slice 6b/6c review, byte-verified pc:202934): re-adding an + /// already-staged item ACCUMULATES onto the existing staged quantity — + /// a prior version of this port upserted/overwrote. Observed through a + /// subsequent Buy All send (the one path that reads + /// directly, unlike Buy + /// Item's own live-slider read). + /// + [Fact] + public void AddToBuyList_ReAddingTheSameStackableItem_AccumulatesTheStagedQuantity() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem( + StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000, + DescStackSize: 100), + }); + h.SplitQuantity.Reset(100u, initialValue: 10u); + h.AddButton.OnClick!.Invoke(); + h.SplitQuantity.SetValue(15u); + h.AddButton.OnClick!.Invoke(); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); // one row, not two + + h.BuyAllButton.OnClick!.Invoke(); + + (_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls); + Assert.Equal(new (int Amount, uint ItemGuid)[] { (25, StackedItemGuid) }, items); + } + + /// + /// F2: retail's 5000-unit cap (VendorStagingList.MaxStagedQuantity) + /// on an ACCUMULATE rejects with its own notice and leaves the entry + /// unchanged. + /// + [Fact] + public void AddToBuyList_AccumulatingPastTheCap_ShowsRetailsNoticeAndLeavesStagingUnchanged() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem( + StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000, + DescStackSize: 100), + }); + h.SplitQuantity.Reset(5000u, initialValue: 4990u); + h.AddButton.OnClick!.Invoke(); + h.SplitQuantity.Reset(5000u, initialValue: 20u); + + h.AddButton.OnClick!.Invoke(); + + Assert.Equal(new[] { VendorStagingList.TooMuchMessage }, h.SystemMessages); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + // Boost the player's coin total so a follow-up Buy All send is only + // gated by staging content, not F1's affordability guard, proving + // the rejected accumulate above left the entry at its PRE-add value. + h.Objects.Get(Harness.PlayerGuid)!.Properties.Ints[(uint)PropertyInt.CoinValue] = 10_000_000; + h.BuyAllButton.OnClick!.Invoke(); + (_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls); + Assert.Equal(4990, items.Single().Amount); + } + + /// + /// F2 (Slice 6b/6c review): port of VendorItemsUI::RemoveFromShop + /// (0x004c3ce0) — staging the vendor's ENTIRE limited supply of an + /// item hides its Items-tab row (matching retail's DeleteItem), + /// and un-staging restores it — is + /// recomputed fresh from the untouched snapshot each time, so no + /// separate "restore" code path is needed. + /// + [Fact] + public void StagingConsumesLimitedShopSupply_HidingTheRowWhenExhaustedAndRestoringItWhenUnstaged() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, 2, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100), + }); + Assert.Equal(1, h.ItemList.GetNumUIItems()); + + h.AddButton.OnClick!.Invoke(); // stages 1 of 2 available + Assert.Equal(1, h.ItemList.GetNumUIItems()); // still visible — 1 remains + + h.AddButton.OnClick!.Invoke(); // stages 2 of 2 available — exhausted + + Assert.Equal(0, h.ItemList.GetNumUIItems()); // row hidden + Assert.Null(h.Selection.SelectedObjectId); // selection cleared, matching RemoveFromShop's own SetSelectedObject(0,0) + + h.BuyClearListButton.OnClick!.Invoke(); // un-stage everything + + Assert.Equal(1, h.ItemList.GetNumUIItems()); // row restored + Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); + } + + /// F2: retail's var_c != 0xffffffff guard — unlimited stock (StackSize == -1) is never hidden. + [Fact] + public void UnlimitedSupplyShopItem_IsNeverHiddenNoMatterHowMuchIsStaged() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100), + }); + + for (int i = 0; i < 5; i++) + h.AddButton.OnClick!.Invoke(); + + Assert.Equal(1, h.ItemList.GetNumUIItems()); + } + [Fact] public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess() { @@ -1427,6 +1552,90 @@ public sealed class VendorUiControllerTests Assert.Empty(h.BuyAlls); } + // ══════════════════════════════════════════════════════════════════════ + // F1 (Slice 6b/6c review) — Buy All's four client-side pre-send guards. + // Each blocking guard leaves staging fully intact; the existing + // BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess + // test above is the "all four pass" case (it already exercises the + // Harness's 102 item / 7 container capacity headroom). + // ══════════════════════════════════════════════════════════════════════ + + [Fact] + public void BuyAllButton_InsufficientPyrealFunds_BlocksWithRetailsNoticeAndStagingIntact() + { + var h = new Harness(); + // Value=2000, no DescStackSize -> perUnit=2000; SellPrice(2000, Armor, + // 1.5, 1) = ceil(3000 - 0.1) = 3000, well above the Harness's 1500 coin default. + h.State.Apply(VendorGuid, Profile(sellRate: 1.5f), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2000), + }); + h.AddButton.OnClick!.Invoke(); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + + h.BuyAllButton.OnClick!.Invoke(); + + Assert.Empty(h.BuyAlls); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages); + } + + [Fact] + public void BuyAllButton_InsufficientAltCurrency_BlocksWithRetailsNoticeAndStagingIntact() + { + var h = new Harness(); + h.State.Apply( + VendorGuid, + Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 10u), + new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + + h.BuyAllButton.OnClick!.Invoke(); + + Assert.Empty(h.BuyAlls); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages); + } + + [Fact] + public void BuyAllButton_InsufficientContainerSlots_BlocksWithRetailsNoticeAndStagingIntact() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100), + }); + h.AddButton.OnClick!.Invoke(); + h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 0; + + h.BuyAllButton.OnClick!.Invoke(); + + Assert.Empty(h.BuyAlls); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages); + } + + [Fact] + public void BuyAllButton_InsufficientItemSlots_BlocksWithRetailsNoticeAndStagingIntact() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + h.Objects.Get(Harness.PlayerGuid)!.ItemsCapacity = 0; + + h.BuyAllButton.OnClick!.Invoke(); + + Assert.Empty(h.BuyAlls); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages); + } + [Fact] public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess() { @@ -1483,6 +1692,39 @@ public sealed class VendorUiControllerTests Assert.Empty(h.Buys); } + /// + /// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must + /// visibly move the highlight — a prior version of this port only + /// repainted the Buying/Selling strips when their OWN staging list + /// changed, so a pure selection change (clicking a DIFFERENT already- + /// staged row, no staging mutation) left both rows' Selected + /// flags stale. + /// + [Fact] + public void SelectingADifferentStagedBuyingRow_RepaintsBothRowsHighlight() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150), + }); + h.ItemList.GetItem(0)!.Clicked?.Invoke(); + h.AddButton.OnClick!.Invoke(); + h.ItemList.GetItem(1)!.Clicked?.Invoke(); + h.AddButton.OnClick!.Invoke(); + Assert.Equal(2, h.BuyingList.GetNumUIItems()); + // AnotherArmorItemGuid is currently selected+highlighted (last staged). + + UiItemSlot firstRow = h.BuyingList.GetItem(0)!; + UiItemSlot secondRow = h.BuyingList.GetItem(1)!; + firstRow.Clicked?.Invoke(); // a pure selection change — no staging mutation + + Assert.Equal(firstRow.ItemId, h.Selection.SelectedObjectId); + Assert.True(firstRow.Selected); + Assert.False(secondRow.Selected); + } + // ══════════════════════════════════════════════════════════════════════ // Slice 6c — Selling tab drag-to-sell staging // ══════════════════════════════════════════════════════════════════════ @@ -1546,6 +1788,82 @@ public sealed class VendorUiControllerTests Assert.Empty(h.SystemMessages); } + /// + /// F5 (Slice 6b/6c review): every drag test above (and + /// + /// below) calls OnDragOver/HandleDropRelease DIRECTLY, + /// bypassing 's real pointer pipeline entirely — the + /// review flagged that this can never exercise the NEW auto-switch + /// behavior (PollDragOver), which reacts to / + /// /, not a direct + /// method call. This test drives the WHOLE thing through + /// // + /// /: lift a drag + /// from an (unrelated) source cell, hover it over the vendor window + /// while the Items tab is still open, let the periodic global-UI-time + /// pulse (retail's message 3) auto-switch to Selling, then release + /// ONTO the now-visible Selling list's real hit-test geometry. + /// + [Fact] + public void DragOverTheVendorWindow_ThroughTheRealPointerPipeline_AutoSwitchesToSellingAndStages() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + + // The Selling list needs real screen geometry (and at least one + // empty-slot placeholder cell) to be hit-testable — the hand-built + // harness never runs a real DAT-driven layout pass, so this test + // gives it one explicitly rather than relying on production sizing. + // Exactly ONE cell's worth of width so the post-drop repaint has no + // room left for an extra empty-slot placeholder alongside the real + // staged item. + h.SellingList.Width = 32f; + h.SellingList.Height = 32f; + using (h.SellingList.DeferLayout()) { } + + // A standalone drag SOURCE cell standing in for "the player's own + // inventory panel" (not modeled by this harness) — positioned well + // outside the vendor window (which occupies roughly (0,0)-(800,110) + // here) so it cannot be confused with any vendor widget. + var sourceCell = new UiItemSlot { Left = 700f, Top = 550f, Width = 32f, Height = 32f }; + sourceCell.SetItem(PlayerOwnedArmorGuid, 0u); + h.Screen.AddChild(sourceCell); + + Assert.True(h.ItemsPage.Visible); + Assert.False(h.SellingPage.Visible); + + // Press on the source cell, then move past the 3px promotion + // threshold to somewhere INSIDE the vendor window (but not + // specifically over the Selling list) while the Items tab is still + // showing — mirrors a player dragging toward "the vendor" in + // general before the panel has switched tabs for them. + h.Screen.OnMouseDown(UiMouseButton.Left, 710, 560); + h.Screen.OnMouseMove(400, 50); + Assert.Same(sourceCell, h.Screen.DragSource); + Assert.False(h.SellingPage.Visible); + + // F5: the auto-switch happens on the periodic global-UI-time pulse, + // not on the mouse-move itself — retail polls this from + // UpdateDragOver via UI message 3. + h.Screen.Tick(0.016, 1L); + + Assert.True(h.SellingPage.Visible); + Assert.False(h.ItemsPage.Visible); + + // Move onto the now-visible Selling list's real geometry and + // release — UiRoot's own hit-test resolves the drop, not a direct + // HandleDropRelease call. + h.Screen.OnMouseMove(20, 15); + h.Screen.OnMouseUp(UiMouseButton.Left, 20, 15); + + Assert.Null(h.Screen.DragSource); + Assert.Equal(1, h.SellingList.GetNumUIItems()); + Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId); + Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId); + Assert.Empty(h.SystemMessages); + } + [Fact] public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt() { @@ -1564,6 +1882,32 @@ public sealed class VendorUiControllerTests Assert.Empty(h.SystemMessages); } + /// + /// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records + /// the item's FULL stack — retail's AddItemToSell stages via a + /// LITERAL -1 "full stack" argument + /// (gmVendorUI::AddItem(..., -1, ...), pc:203595), never a + /// slider read. A prior version of this port read the LIVE split + /// slider here instead — this proves a PARTIAL slider selection at + /// drop time does not leak into the staged (or sent) quantity. + /// + [Fact] + public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20); + h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor); + h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored + + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid)); + h.SellAllButton.OnClick!.Invoke(); + + (_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells); + Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items); + } + [Fact] public void HandleDropRelease_WrongTargetList_IsIgnored() { @@ -1631,6 +1975,35 @@ public sealed class VendorUiControllerTests Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages); } + /// + /// F4 (Slice 6b/6c review): BF_RETAINED is now checked end to + /// end — an item whose TYPE matches the vendor's merchandise mask is + /// still rejected (as the generic WrongType message, matching + /// retail's OR'd branch) when its bitfield carries the bit. + /// + [Fact] + public void HandleDropRelease_RetainedItem_RejectsEvenWhenTheTypeMaskMatches() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = PlayerOwnedArmorGuid, + Name = "Heirloom Chainmail", + Type = ItemType.Armor, + Value = 100, + StackSize = 1, + PublicWeenieBitfield = (uint)PublicWeenieFlags.Retained, + }); + h.Objects.MoveItem(PlayerOwnedArmorGuid, Harness.PlayerGuid, 0); + + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages); + } + [Fact] public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess() { @@ -1680,6 +2053,71 @@ public sealed class VendorUiControllerTests Assert.Equal(0, h.SellingList.GetNumUIItems()); } + /// + /// F13 (Slice 6b/6c review): retail's Sell Item reads + /// ACCWeenieObject::selectedID UNCONDITIONALLY — there is no + /// "must be staged first" requirement. A prior version of this port + /// required a matching _sellStaging entry, which this test would + /// have failed against (no drop/staging happens here at all — only a + /// direct global selection). + /// + [Fact] + public void SellItemButton_ActsOnTheGlobalSelectionEvenWhenNeverStaged() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + h.Selection.Select(PlayerOwnedArmorGuid, SelectionChangeSource.Vendor); + Assert.Equal(0, h.SellingList.GetNumUIItems()); // never staged/dropped + + h.SellItemButton.OnClick!.Invoke(); + + (uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells); + Assert.Equal(VendorGuid, vendorGuid); + Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items); + } + + /// + /// F6 (Slice 6b/6c review, byte-verified pc:201833-201864): + /// SellSingleItem refuses a stackable item whose split slider is + /// not showing the FULL stack — shows the exact retail notice and sends + /// nothing. + /// + [Fact] + public void SellItemButton_PartialStackSelected_RefusesWithRetailsNoticeAndSendsNothing() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20); + h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor); + h.SplitQuantity.Reset(20u, initialValue: 5u); // a PARTIAL amount, not the full stack of 20 + + h.SellItemButton.OnClick!.Invoke(); + + Assert.Empty(h.Sells); + Assert.Equal(new[] { "Cannot sell part of a stack" }, h.SystemMessages); + } + + /// + /// F6: once the slider shows the FULL stack, Sell Item proceeds and + /// sends amount 1 LITERALLY — not the 20-unit stack size — + /// matching retail's own literal var_9c = 1 send. + /// + [Fact] + public void SellItemButton_FullStackSelected_SendsLiteralAmountOneNotTheStackSize() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20); + h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor); + h.SplitQuantity.Reset(20u, initialValue: 20u); // the FULL stack + + h.SellItemButton.OnClick!.Invoke(); + + (_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells); + Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedWeaponGuid) }, items); + } + [Fact] public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling() { @@ -1713,6 +2151,65 @@ public sealed class VendorUiControllerTests Assert.Empty(h.Sells); } + // ══════════════════════════════════════════════════════════════════════ + // F10 (Slice 6b/6c review) — unstage on removal/dispossession. + // ══════════════════════════════════════════════════════════════════════ + + /// + /// Sell side — retail's RecvNotice_ServerSaysMoveItem + /// (0x004c44a0) silently unstages a staged sell item once it's + /// no longer trackable/owned by the player; this port's closest + /// reachable analogue is the item leaving ClientObjectTable + /// entirely. + /// + [Fact] + public void RemovingAStagedSellItemFromClientObjectTable_SilentlyUnstagesIt() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + Assert.Equal(1, h.SellingList.GetNumUIItems()); + + h.Objects.Remove(PlayerOwnedArmorGuid); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Empty(h.SystemMessages); // silent — matches retail's own site + } + + /// + /// Buy side — a staged shop item that drops out of the vendor's + /// CURRENT stock (retired from ClientObjectTable — production's + /// VendorShopItemMaterializer does this on every Refreshed + /// transition that drops a guid; this hand-built harness doesn't mount + /// that class, so the test performs the SAME removal directly) unstages + /// with retail's exact "Removing %s from shopping list" notice + /// (0x004c4246). + /// + [Fact] + public void ShopItemLeavingClientObjectTable_UnstagesTheBuyEntryWithRetailsNotice() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = ArmorItemGuid, + Name = "Chainmail", + Type = ItemType.Armor, + ContainerId = VendorGuid, + }); + h.AddButton.OnClick!.Invoke(); + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + + h.Objects.Remove(ArmorItemGuid); + + Assert.Equal(0, h.BuyingList.GetNumUIItems()); + Assert.Equal(new[] { "Removing Chainmail from shopping list" }, h.SystemMessages); + } + // ══════════════════════════════════════════════════════════════════════ // Slice 6b/6c — X-close staging confirmation + session-boundary clears // ══════════════════════════════════════════════════════════════════════ @@ -1749,9 +2246,11 @@ public sealed class VendorUiControllerTests Assert.True(h.Dialogs.IsOpen); Assert.NotNull(h.ShownDialog); // Exact retail string, read from the decompiled binary's data - // segment at 0x007b5bd8 — see CloseButtonPressed's doc comment. + // segment at 0x007b5bd8 — see CloseButtonPressed's doc comment. F7 + // (Slice 6b/6c review): the raw bytes right after the declared + // string length are UTF-16LE for '?' before the null terminator. Assert.Equal( - "You have not completed all transactions. Are you sure you want to leave this vendor", + "You have not completed all transactions. Are you sure you want to leave this vendor?", string.Join(" ", Assert.IsType(h.ShownDialog!.FindElement( RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text))); } diff --git a/tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs b/tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs index 35188fd5..ec903a79 100644 --- a/tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs +++ b/tests/AcDream.Core.Tests/Items/VendorSellAcceptabilityTests.cs @@ -136,6 +136,83 @@ public sealed class VendorSellAcceptabilityTests Assert.Equal(VendorSellRejection.None, rejection); } + /// + /// F3 (Slice 6b/6c review, byte-verified at 0x005d1add): a trade note + /// above the vendor's max value is EXEMPT — the actual x86 is + /// (~(itemTypeMask >> 16)) & 4, and PromissoryNote's bit + /// (0x00040000) lands exactly on that mask, zeroing the result. + /// + [Fact] + public void PromissoryNoteAboveMaxValueIsExemptFromTooValuable() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: (uint)ItemType.PromissoryNote, + perUnitValue: 999_999, + merchandiseItemTypes: (uint)ItemType.PromissoryNote, + merchandiseMinValue: 0u, + merchandiseMaxValue: 1000u); + + Assert.Equal(VendorSellRejection.None, rejection); + } + + /// An ordinary (non-note) item above max value is still rejected — the exemption is note-specific. + [Fact] + public void NonPromissoryNoteAboveMaxValueIsStillTooValuable() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: 999_999, + merchandiseItemTypes: Armor, + merchandiseMinValue: 0u, + merchandiseMaxValue: 1000u); + + Assert.Equal(VendorSellRejection.TooValuable, rejection); + } + + /// + /// F4 (Slice 6b/6c review): BF_RETAINED + /// (, 0x01000000) folds into + /// the SAME outcome as a + /// genuine type mismatch — retail's InqAcceptability ORs the two + /// conditions together (pc:005d1aa7). + /// + [Fact] + public void RetainedItemIsRejectedAsWrongTypeEvenWhenTheTypeMaskMatches() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: 100, + merchandiseItemTypes: Armor, + merchandiseMinValue: 0u, + merchandiseMaxValue: NoLimit, + publicWeenieBitfield: (uint)PublicWeenieFlags.Retained); + + Assert.Equal(VendorSellRejection.WrongType, rejection); + } + + /// An item with OTHER bitfield bits set (not Retained) is unaffected. + [Fact] + public void NonRetainedBitfieldDoesNotAffectAcceptability() + { + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: 0, + itemTypeMask: Armor, + perUnitValue: 100, + merchandiseItemTypes: Armor, + merchandiseMinValue: 0u, + merchandiseMaxValue: NoLimit, + publicWeenieBitfield: (uint)PublicWeenieFlags.Stuck); + + Assert.Equal(VendorSellRejection.None, rejection); + } + [Fact] public void AnOrdinaryAcceptableItemReturnsNone() { diff --git a/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs b/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs index 40048976..fde7ad5b 100644 --- a/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs +++ b/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs @@ -26,8 +26,14 @@ public sealed class VendorStagingListTests Assert.False(list.IsEmpty); } + /// + /// F2 (Slice 6b/6c review, byte-verified pc:202934): re-adding an + /// already-staged guid ACCUMULATES onto the existing quantity — a prior + /// version of this port upserted/overwrote instead, which this test + /// used to assert (20, not 25). + /// [Fact] - public void AddingTheSameGuidTwiceUpsertsRatherThanDuplicating() + public void AddingTheSameGuidTwiceAccumulatesRatherThanDuplicatingOrOverwriting() { var list = new VendorStagingList(); @@ -35,7 +41,64 @@ public sealed class VendorStagingListTests list.Add(ItemA, 20); VendorStagingEntry entry = Assert.Single(list.Entries); - Assert.Equal(20, entry.Quantity); + Assert.Equal(25, entry.Quantity); + } + + [Fact] + public void AddReturnsAddedOnASuccessfulStage() + { + var list = new VendorStagingList(); + + Assert.Equal(VendorStagingAddOutcome.Added, list.Add(ItemA, 5)); + Assert.Equal(VendorStagingAddOutcome.Added, list.Add(ItemA, 5)); + } + + /// + /// F2: retail's 5000-unit cap (0x1388) guards the ACCUMULATE + /// path only — exceeding it on a re-add rejects and leaves the entry + /// COMPLETELY UNCHANGED (pc:202936-202951). + /// + [Fact] + public void AddAccumulatingPastTheCapIsRejectedAndLeavesTheEntryUnchanged() + { + var list = new VendorStagingList(); + list.Add(ItemA, VendorStagingList.MaxStagedQuantity - 10); + + VendorStagingAddOutcome outcome = list.Add(ItemA, 11); + + Assert.Equal(VendorStagingAddOutcome.Capped, outcome); + VendorStagingEntry entry = Assert.Single(list.Entries); + Assert.Equal(VendorStagingList.MaxStagedQuantity - 10, entry.Quantity); + } + + [Fact] + public void AddAccumulatingExactlyToTheCapSucceeds() + { + var list = new VendorStagingList(); + list.Add(ItemA, VendorStagingList.MaxStagedQuantity - 10); + + VendorStagingAddOutcome outcome = list.Add(ItemA, 10); + + Assert.Equal(VendorStagingAddOutcome.Added, outcome); + VendorStagingEntry entry = Assert.Single(list.Entries); + Assert.Equal(VendorStagingList.MaxStagedQuantity, entry.Quantity); + } + + /// + /// F2: a BRAND-NEW entry has no cap in retail's own AddToBuyList + /// — the cap check lives only inside the "found an existing match" + /// branch (label_4c3e20's insert has none, pc:202895-202923). + /// + [Fact] + public void AddOfABrandNewEntryHasNoCapEvenAboveTheThreshold() + { + var list = new VendorStagingList(); + + VendorStagingAddOutcome outcome = list.Add(ItemA, VendorStagingList.MaxStagedQuantity + 500); + + Assert.Equal(VendorStagingAddOutcome.Added, outcome); + VendorStagingEntry entry = Assert.Single(list.Entries); + Assert.Equal(VendorStagingList.MaxStagedQuantity + 500, entry.Quantity); } [Theory] @@ -46,8 +109,9 @@ public sealed class VendorStagingListTests { var list = new VendorStagingList(); - list.Add(guid, quantity); + VendorStagingAddOutcome outcome = list.Add(guid, quantity); + Assert.Equal(VendorStagingAddOutcome.Ignored, outcome); Assert.True(list.IsEmpty); }