diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4f33e67b..ecb99940 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) — 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) +## 3. Documented approximation (AP) — 120 active rows (AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; 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 @@ -295,7 +295,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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-168 | **NARROWED 2026-08-08 (grand-gate finding G1) — the player's-OWN-pack half (`CountPlayerContents`) is FIXED; only the shop-stock half (`ComputeBuySlotsNeeded`) remains approximated.** Live testing surfaced the risk this row already predicted: "Buy All" false-blocked a container purchase while the player visibly had free container slots. Root cause was NOT the theoretical corner case originally described here — it was that the old dual-heuristic (`ItemType.Container` bit OR nonzero `ItemsCapacity`/`ContainersCapacity`) could over-classify an ordinary non-container object as an occupied container slot, undercounting free space. `CountPlayerContents` now reads `ClientObject.ContainerTypeHint` first — retail's actual wire `ContainerProperties` (`Item_ServerSaysContainId` 0x0022's `ContainerType`; also carried by `ContentProfile`/`PlayerDescription`'s per-entry container-kind byte), already threaded onto every owned object by `InitializeInventoryManifest`/`ApplyConfirmedServerMove`/`ReplaceContents` and already used for this identical question by `ClientObjectTable.IsContainerListMember` — falling back to `ItemType.Container` alone (the capacity-field legs were dropped) only for the rare object that never received a hint. This matches retail's real `_itemsList`/`_containersList` bucketing (`ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` @0x0058beb0/0x0058bec0 just report already-bucketed `IDList` lengths; the bucketing happens once, at insert time, in `ServerSaysContainID` @0x0058be40, from that same wire field) rather than reconstructing it from the item's own type/capacity fields. Original text: **Filed 2026-08-09, Opus review of `92ea3977`, finding F1 (Buy All's client pre-send capacity guard).** Retail's `gmVendorUI::InqListSlotCount` (`pc:200038-200065`, `0x004c0c10`) classifies each staged item as needing a CONTAINER slot or an ITEM slot by testing a bitfield bit (a decompiler string-misattribution artifact not yet decoded) ORed with the item's own nonzero `_itemsCapacity`/`_containersCapacity`. `VendorUiController.ComputeBuySlotsNeeded`/`CountPlayerContents` approximate this with `(item.ItemType & ItemType.Container) != 0` instead — correct for the ordinary case (an authored backpack/pouch DOES carry the `Container` type bit) but not byte-identical for the theoretical case of a non-`Container`-typed item that still authors nonzero pack/side capacities (or vice versa, a `Container`-typed item with zero capacity of its own, e.g. a locked/sealed decorative chest never meant to be carried). | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ComputeBuySlotsNeeded`, `CountPlayerContents`) | `VendorShopItem`'s wire shape (Slice 5's deliberately narrow browse-scope subset) genuinely does not carry `PublicWeenieBitfield`/`ItemsCapacity`/`ContainersCapacity`/`ContainerProperties` the way `ClientObject` does for an ordinary `CreateObject`/membership-sourced item, so `ComputeBuySlotsNeeded` (the shop-stock side, staged-but-not-yet-owned items) cannot read a wire-truth hint the way the fixed `CountPlayerContents` (the already-owned side) now does; extending the DTO was out of scope for this fix. The server remains authoritative and re-validates real pack-space regardless (`Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`) — the residual failure mode stays UX/latency, not correctness. | A vendor selling a `Container`-typed item with zero authored capacity (rare/decorative) would still be misclassified as needing a container slot instead of an item slot, or vice versa for a non-`Container`-typed item that DOES author capacity (also rare) — the pre-check could still reject a purchase retail's own guard would have allowed, or allow one retail would have blocked, purely on the CLIENT side for the SHOP-STOCK item being bought; the player's-OWN-pack accounting that drives the free-slot count is no longer the source of that risk. | `gmVendorUI::InqListSlotCount` `pc:200038-200065`/`0x004c0c10`; `ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` `0x0058beb0`/`0x0058bec0`; `ACCWeenieObject::ServerSaysContainID` `0x0058be40`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` | +| AP-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live).** Retail's `gmToolbarUI::HandleSelectionChanged` reads `eax_5->pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) uniformly for both owned-inventory and vendor-owned selections — the same `PublicWeenieDesc::_stackSize` field an ordinary `CreateObject` carries. The user's local ACE server never populates that field for a vendor browse-list row: `Vendor.LoadInventoryItem` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:144-172`) builds the listing `WorldObject` via `WorldObjectFactory.CreateNewWorldObject` and sets only `wo.VendorShopCreateListStackSize` (our `VendorShopItem.StackSize`, the packed ItemProfile "how many for sale" dword) — it never calls `wo.SetStackSize(...)`, so `PublicWeenieDesc`'s own conditional `StackSize` field (our `VendorShopItem.DescStackSize`, what `GameEventApproachVendor.cs:60`'s `obj.SerializeGameDataOnly` walks) comes back null on the real wire for every vendor listing, live-verified with a materializer-level reproduction test. `VendorShopItemMaterializer.ToWeenieData` now prefers `DescStackSize` when the wire genuinely carries it (retail-faithful first — a real retail server, or a future ACE fix, populating `_stackSize` normally is honored unchanged), falling back to the packed `StackSize` supply-count field (clamped positive) when it doesn't — the field ACE DOES reliably send. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`ToWeenieData`, `ResolveDisplayStackSize`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's own client-side read (`pwd._stackSize`) is unchanged and still tried first; only the fallback source differs, and only because the live server this port talks to (the user has no other AC server available) never fills the field retail's own server apparently does. The packed supply-count field is the only OTHER wire-carried "how many" signal `VendorShopItem` has. | The packed field's `-1` "unlimited supply" sentinel has no bounded per-row purchase cap in `VendorShopItem`'s current wire shape (no `_maxStackSize` carried), so an unlimited-supply vendor stack still falls through to the safe non-splittable default (slider absent, single-unit buy) rather than a genuine retail-faithful per-row cap — a SMALLER residual than the pre-fix "no vendor stack ever shows a slider" defect, but not zero. A finite-quantity vendor stack (the common case, matching the live gate's arrows/trade-notes report) now shows the slider correctly. | `gmToolbarUI::HandleSelectionChanged` `pc:198635-198930`/`0x004bf380` (stack-size reads at `pc:198688`/`198744`/`198774`/`198791`); `Vendor.LoadInventoryItem` `Vendor.cs:144-172`; `GameEventApproachVendor.cs:52-61` | +| AP-170 | **Filed 2026-08-08, grand-gate finding G3 (out-of-range vendor Use lost silently).** Retail's `ItemHolder::UseObject @ 0x00588A80` has no client-side range check and sends Use immediately regardless of distance — this port's ORIGINAL `RequestUse` faithfully mirrored that shape. Live testing against the user's local ACE server showed it does not hold: walking to a vendor and using it from out of range plays the vendor's cosmetic greeting (a distance-only reaction, independent of Use) but never opens the shop panel — `ApproachVendor` never arrives. ACE's `Player.HandleActionUseItem` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`) explains why: an out-of-range target routes through `CreateMoveToChain(item, (success) => TryUseItem(item, success))` (`Player_Move.cs:37-96`), which polls every 0.1s for the player to reach `WithinUseRadius` and only then calls `ActOnUse` — it does not teleport or server-move the player; it waits for the CLIENT's own walk to land, and a Use that arrives before that poll ever starts observing an in-range player is simply never followed by the vendor's `ApproachVendor` send (`Vendor.ActOnUse`'s own doc comment: "the player will have been commanded to move using `DoMoveTo` before `ActOnUse` is called... it should be assumed that the player is within range" — a precondition our immediate send violated). `SelectionInteractionController.RequestUse` now arms the out-of-range case on the SAME arrival-gated shape `SendPickup`'s close-range (turn-only) branch already used (`RuntimeInteractionTransactionState.TryArmPostArrivalUse`/`TryResolveUseApproachCompletion`, mirroring `TryArmPostArrivalPickup`/`TryResolveApproachCompletion` field-for-field) — the wire Use dispatches only once the local approach naturally completes. An already-in-range Use (a turn at most, or no approach concept applies) is unaffected and still sends immediately, matching ACE's own "already within use distance" synchronous callback. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`RequestUse`, `HandleApproachCompletion`, `HandleUseApproachCompletion`, `CancelPendingApproach`, `OnEntityHidden`, `OnEntityRemoved`); `src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs` (`RuntimePendingUse`, `TryArmPostArrivalUse`, `TryResolveUseApproachCompletion`, `TryCancelPendingUse`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's REAL server walks the player itself before the target's `ActOnUse` ever sees the request, so the client's immediate send never races anything there. ACE does not do this for a player-initiated Use — it only polls and waits — so arming on arrival is required for correctness against the only server this port can test against, not a stylistic preference. | An interaction path that still calls `TryDispatchUse` directly without going through `RequestUse`'s approach gate (none identified at this fix) would keep the original race. The armed reservation is a live busy-count reference until arrival/cancellation resolves it; `ResetCore` releases it unconditionally on any reset/dispose so a teardown that runs without a preceding `CancelPendingApproach()` (e.g. a headless/no-window host with no `SelectionInteractionController`) cannot leak it. | `ItemHolder::UseObject` `0x00588A80`; `Player.HandleActionUseItem` `Player_Use.cs:176-215`; `Player.CreateMoveToChain`/`MoveToChain` `Player_Move.cs:37-153`; `Vendor.ActOnUse` `Vendor.cs:223-266` | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | 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 c42ab81b..78607dfd 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -214,34 +214,49 @@ internal sealed class SelectionInteractionController public void SendUse(uint serverGuid) => RequestUse(serverGuid, reservation: null); + /// + /// G3 (grand-gate finding, 2026-08-08, register AP-170): an out-of-range + /// Use no longer sends the wire request immediately — it arms on the + /// SAME arrival-gated shape 's close-range + /// (turn-only) branch already uses, dispatching only once the approach + /// naturally completes. + /// + /// Why this deviates from retail's own literal + /// ItemHolder::UseObject @ 0x00588A80 send-immediately shape. + /// Retail's REAL server walks the player itself before the target's + /// ActOnUse handler ever sees the request — the client is free to + /// fire immediately because the server-side arrival gate is invisible to + /// it. ACE does not do this for a player-initiated Use: live testing + /// against the user's local ACE server (2026-08-08) showed a vendor + /// approached from out of range plays its cosmetic greeting (a + /// distance-only reaction independent of the Use action) but never opens + /// the shop panel — ApproachVendor never arrives. ACE's own + /// Player.HandleActionUseItem (Player_Use.cs:176-215) + /// confirms why: an out-of-range target routes through + /// CreateMoveToChain(item, (success) => TryUseItem(item, success)) + /// (Player_Move.cs:37-96), which POLLS every 0.1s for the player + /// to reach WithinUseRadius and only then calls + /// TryUseItem/ActOnUse — it does not teleport or + /// server-move the player; it waits for the client's own walk to land. + /// Sending the wire Use before OUR client has actually arrived races + /// that poll and can lose. Retail's client-side immediacy assumption + /// (this method's ORIGINAL design, see the register) does not hold + /// against this server; arming on arrival closes the gap by construction + /// instead of racing it. + /// + /// + /// F11 (Slice 6b/6c review, preserved): the eligibility test + /// (ownedByPlayer || useable) is still computed ONCE, up front, + /// before any approach or arm — an ineligible target never kicks off a + /// wasted walk. + /// + /// public void RequestUse( uint serverGuid, ItemUseRequestReservation? reservation) { CancelPendingApproach(); - // ItemHolder::UseObject @ 0x00588A80 has no distance/range check — - // retail's client sends Use unconditionally regardless of range; the - // walk-in is entirely server-driven (ACE's CreateMoveToChain, - // Player_Move.cs:37-65) and arrives back as an ordinary broadcast - // motion command (Q2, docs/research/2026-08-08-slice6b-vendor- - // completion-research.md). This mirrors SendPickup's !IsCloseRange - // branch below: kick off the SAME local client-predicted - // MoveToObject animation for immediate visual feel, but never gate - // the wire send on its arrival — unlike Pickup's close-range - // TurnToObject branch, Use keeps sending immediately either way (the - // existing RuntimeInteractionTransactionState.TryDispatchUse doc - // comment: "consume the strict 0.2-second gate, send immediately"). - // - // 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); @@ -249,9 +264,48 @@ internal sealed class SelectionInteractionController && _query.TryGetApproach(serverGuid, out InteractionApproach approach) && !approach.IsCloseRange) { - _movement.BeginApproach(approach); + // Genuinely out of range (a real walk, not just a turn) — + // mirror SendPickup's arrival-gated shape: arm the transaction + // on the approach token BEFORE the movement starts (so a + // synchronously-completing approach can't race the arm), then + // let HandleApproachCompletion dispatch on natural arrival. + bool armed = false; + bool started = _movement.BeginApproach( + approach, + token => + { + armed = _transactions.TryArmPostArrivalUse( + serverGuid, + ownedByPlayer, + useable, + reservation, + new RuntimeInteractionApproachToken( + token.ControllerLifetime, + token.ApproachGeneration), + out _); + }); + if (!started || !armed) + { + // Release whatever got captured (or the caller's own + // reservation, if arming never stored it) — mirrors + // SendPickup's !started/!armed cleanup shape. + if (_transactions.TryCancelPendingUse( + serverGuid, out RuntimePendingUse cancelled)) + { + cancelled.Reservation?.CancelBeforeDispatch(); + } + else + { + reservation?.CancelBeforeDispatch(); + } + } + return; } + // Already in range (a turn at most, or no approach concept applies) + // — keep retail's immediate send; ACE's own "already within use + // distance" branch (Player_Move.cs:65-87) calls back synchronously, + // so there is no arrival gap to race here. RuntimeInteractionDispatchResult result = _transactions.TryDispatchUse( serverGuid, @@ -407,20 +461,44 @@ internal sealed class SelectionInteractionController /// Fires only after natural MoveToComplete(None), never cancellation. public void OnNaturalMoveToComplete() { - if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pending)) - HandleApproachCompletion(pending.ApproachToken, natural: true); + if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pendingPickup)) + { + HandleApproachCompletion(pendingPickup.ApproachToken, natural: true); + return; + } + // G3: at most one of {pendingPickup, pendingUse} is ever armed — + // CancelPendingApproach() clears any prior one before a new + // SendPickup/RequestUse arms another. + if (_transactions.TryGetPendingUse(out RuntimePendingUse pendingUse)) + HandleApproachCompletion(pendingUse.ApproachToken, natural: true); } private void HandleApproachCompletion( RuntimeInteractionApproachToken approachToken, bool natural) { - bool accepted = _transactions.TryResolveApproachCompletion( + bool pickupAccepted = _transactions.TryResolveApproachCompletion( approachToken, natural, - out RuntimePendingPickup pending); - if (pending.Token == 0u) + out RuntimePendingPickup pendingPickup); + if (pendingPickup.Token != 0u) + { + HandlePickupApproachCompletion(pendingPickup, pickupAccepted); return; + } + + bool useAccepted = _transactions.TryResolveUseApproachCompletion( + approachToken, + natural, + out RuntimePendingUse pendingUse); + if (pendingUse.Token != 0u) + HandleUseApproachCompletion(pendingUse, useAccepted); + } + + private void HandlePickupApproachCompletion( + RuntimePendingPickup pending, + bool accepted) + { if (!accepted) { CancelPickupPresentation( @@ -455,6 +533,41 @@ internal sealed class SelectionInteractionController } } + /// + /// G3: dispatches an armed Use on natural arrival. A cancelled approach + /// ( false — supersede/move-away) releases + /// the reservation directly; + /// already resolves the reservation on every one of its own outcomes + /// (dispatched or rejected), so no separate release is needed past that + /// point. + /// + private void HandleUseApproachCompletion( + RuntimePendingUse pending, + bool accepted) + { + if (!accepted) + { + pending.Reservation?.CancelBeforeDispatch(); + return; + } + + RuntimeInteractionDispatchResult result = + _transactions.TryDispatchUse( + pending.ServerGuid, + pending.OwnedByPlayer, + pending.Useable, + pending.Reservation, + _transport, + out uint sequence); + if (result == RuntimeInteractionDispatchResult.NotInWorld) + _toast?.Invoke("Not in world"); + if (result == RuntimeInteractionDispatchResult.Dispatched) + { + Console.WriteLine( + $"[B.4b] use guid=0x{pending.ServerGuid:X8} seq={sequence} (arrival-gated)"); + } + } + public void DrainOutbound() { while (_approachCompletions.TryTake(out PlayerApproachCompletion completion)) @@ -482,6 +595,11 @@ internal sealed class SelectionInteractionController cancelled.ServerGuid, cancelled.PendingPlacementToken); } + // G3: an armed out-of-range Use whose target vanished must release + // its reservation too — the approach it was waiting on will never + // naturally complete against a hidden target. + if (_transactions.TryCancelPendingUse(serverGuid, out RuntimePendingUse cancelledUse)) + cancelledUse.Reservation?.CancelBeforeDispatch(); if (_selection.SelectedObjectId == serverGuid) { _selection.Clear( @@ -505,6 +623,10 @@ internal sealed class SelectionInteractionController cancelled.ServerGuid, cancelled.PendingPlacementToken); } + // G3: same as OnEntityHidden — a removed target's armed Use must + // not linger waiting for an approach that can never complete. + if (_transactions.TryCancelPendingUse(record.ServerGuid, out RuntimePendingUse cancelledUse)) + cancelledUse.Reservation?.CancelBeforeDispatch(); if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid) { _selection.Clear( @@ -634,12 +756,18 @@ internal sealed class SelectionInteractionController private void CancelPendingApproach() { - if (!_transactions.TryCancelPendingPickup( + if (_transactions.TryCancelPendingPickup( out RuntimePendingPickup pending)) - return; - CancelPickupPresentation( - pending.ServerGuid, - pending.PendingPlacementToken); + { + CancelPickupPresentation( + pending.ServerGuid, + pending.PendingPlacementToken); + } + // G3: a new SendPickup/RequestUse supersedes whatever approach was + // previously armed — release an in-flight Use's reservation too, not + // just pickup's presentation token. + if (_transactions.TryCancelPendingUse(out RuntimePendingUse pendingUse)) + pendingUse.Reservation?.CancelBeforeDispatch(); } private void DispatchQueuedInteraction( diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index 800e383b..c219d3e8 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -1623,16 +1623,28 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// 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. + /// Container-vs-item slot classification (register AP-168, narrowed + /// G1 gate-finding fix 2026-08-08). 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. This residual applies ONLY to the shop-stock side + /// () — + /// genuinely has no wire-carried classification field to read instead. + /// The player's-OWN-pack side () no + /// longer shares this approximation: it now reads + /// (retail's actual wire + /// ContainerProperties, already threaded onto every owned object) + /// first, matching retail's real _itemsList/_containersList + /// bucketing exactly for anything that ever received a hint. Live + /// testing showed the OLD dual-heuristic (also checking nonzero + /// ItemsCapacity/ContainersCapacity) could over-classify a + /// non-container object as an occupied container slot and false-block a + /// purchase with real free slots — see the register. /// /// On a successful DISPATCH the whole staged list is flushed /// UNCONDITIONALLY and immediately, matching retail's literal order: @@ -1733,7 +1745,35 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return (itemSlots, containerSlots); } - /// F1: the player's CURRENT occupied item/container slot counts. + /// + /// F1 (G1 gate-finding fix, 2026-08-08): the player's CURRENT occupied + /// item/container slot counts. Retail's own GetNumContainedItems/ + /// GetNumContainedContainers (0x0058beb0/0x0058bec0) + /// don't reclassify anything at count time — they just report the length + /// of two ALREADY-BUCKETED IDLists (_itemsList/ + /// _containersList). The bucketing happens once, at INSERT time + /// (ACCWeenieObject::ServerSaysContainID @ 0x0058be40), from the + /// wire's own ContainerProperties field (Item_ServerSaysContainId + /// 0x0022's ContainerType; also carried by ContentProfile / + /// PlayerDescription's per-entry container-kind byte) — a + /// None/Container/Foci discriminator the SERVER computes, not something + /// the client reverse-engineers from the item's own type bits. + /// is exactly that wire + /// field, already threaded through every membership path + /// (InitializeInventoryManifest, InventoryPutObjInContainer, + /// ViewContents) and already used for this identical + /// container-vs-item question elsewhere + /// (ClientObjectTable.IsContainerListMember). This port previously + /// used ONLY the local /capacity-field + /// heuristic here (AP-168) and never consulted the hint — live testing + /// showed that guessing wrong in the OVER-classify direction (a + /// non-container object whose capacity fields happen to read nonzero) + /// false-blocks a purchase with real free slots (#G1). The hint is now + /// authoritative when present; the heuristic is a narrower fallback + /// (Container-typed only, matching 's + /// single signal) for the rare object that reached the table without + /// ever threading a hint. + /// private (int Items, int Containers) CountPlayerContents() { int items = 0, containers = 0; @@ -1741,9 +1781,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag { ClientObject? obj = _objects.Get(guid); bool isContainer = obj is not null - && (obj.ItemsCapacity > 0 - || obj.ContainersCapacity > 0 - || (obj.Type & ItemType.Container) != 0); + && (obj.ContainerTypeHint != 0u || (obj.Type & ItemType.Container) != 0); if (isContainer) containers++; else items++; } return (items, containers); diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index f71f5c1b..0faeca46 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -123,6 +123,28 @@ public sealed class ClientObjectTable // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. private readonly Dictionary _pendingMoves = new(); + + /// + /// G4 (grand-gate finding, 2026-08-08, register AP-171): an authoritative + /// placement (/) + /// whose item guid does not exist in YET, keyed by + /// that guid — stashed instead of silently dropped, and replayed by + /// the moment that guid's CreateObject arrives. + /// + /// ACE's GameMessageCreateObject rides GameMessageGroup.SmartboxQueue + /// while Item_ServerSaysContainId/InventoryPutObjInContainer + /// (0x0022) rides GameMessageGroup.UIQueue — two independent + /// reliable queues with NO cross-queue ordering guarantee. An ordinary + /// pickup's item guid is already known (it was visible in the 3D world + /// first), so this race can't bite it; a vendor BUY of common stock + /// mints a brand-new guid the client has never seen + /// (Player_Commerce.cs ItemProfileToWorldObjects), so its + /// existence depends entirely on which queue's message the client + /// processes first. + /// + /// + private readonly Dictionary + _pendingUnresolvedPlacements = new(); private ulong _mutationRevision; public ClientObjectTable() @@ -451,6 +473,19 @@ public sealed class ClientObjectTable /// request is reconciled before is published, /// matching retail ServerSaysMoveItem: a reentrant listener may start /// a new request without the old confirmation consuming it afterward. + /// + /// G4 (grand-gate finding, register AP-171): if + /// does not exist yet — the cross-queue race documented on + /// , most visibly a vendor + /// buy's brand-new guid whose InventoryPutObjInContainer (UIQueue) + /// echo can arrive before its own CreateObject (SmartboxQueue) — + /// the requested placement is STASHED rather than silently dropped, and + /// replayed the moment creates that guid. Without + /// this, the item's placement=0 request is lost, and its later + /// CreateObject-only Ingest leaves it wherever the naive + /// container-index append put it (the list tail) instead of retail's + /// requested slot 0 (list head). + /// /// public bool ApplyConfirmedServerMove( uint itemId, @@ -472,6 +507,17 @@ public sealed class ClientObjectTable return true; } + if (itemId != 0u && newContainerId != 0u && !_objects.ContainsKey(itemId)) + { + _pendingUnresolvedPlacements[itemId] = ( + new ClientObjectPlacement( + newContainerId, + newSlot, + newWielderId, + newEquipLocation), + containerTypeHint); + } + ObjectMoved?.Invoke(new ClientObjectMove( itemId, Item: null, @@ -929,6 +975,28 @@ public sealed class ClientObjectTable UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj)); if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); PublishContainerContentsChanges(changedContainers); + + // G4: this guid's CreateObject just arrived. If an authoritative + // placement (Item_ServerSaysContainId/InventoryPutObjInContainer) + // for it had already arrived and been stashed — the cross-queue + // race documented on _pendingUnresolvedPlacements — replay it now + // via the SAME ordered-insert path ApplyServerMove already uses, so + // the retail-requested slot (e.g. 0 — the pack head) wins over the + // naive append Reindex just performed above. Only ever possible for + // a brand-new guid: an existing object's ApplyConfirmedServerMove + // would have found it immediately and never stashed anything. + if (!existed + && _pendingUnresolvedPlacements.Remove( + d.Guid, out var pending)) + { + ApplyServerMove( + d.Guid, + pending.Placement.ContainerId, + pending.Placement.WielderId, + pending.Placement.ContainerSlot, + pending.Placement.EquipLocation, + pending.ContainerTypeHint); + } return obj; } @@ -1506,6 +1574,7 @@ public sealed class ClientObjectTable _containerIndex.Clear(); _equipmentIndex.Clear(); _pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback) + _pendingUnresolvedPlacements.Clear(); // G4: drop stashed placements for a session that's ending anyway Cleared?.Invoke(); } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs index 5a44b09b..b32c9609 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs @@ -31,6 +31,23 @@ public readonly record struct RuntimePendingPickup( ulong PendingPlacementToken, RuntimeInteractionApproachToken ApproachToken); +/// +/// G3 (grand-gate finding): an out-of-range Use armed to dispatch on +/// arrival, mirroring 's shape. Holds the +/// eligibility snapshot computed at request time (retail's own +/// ItemHolder::UseObject eligibility test runs once, before the +/// walk-in) plus the caller's , which +/// crosses the approach boundary unresolved until arrival (dispatch) or +/// cancellation. +/// +public readonly record struct RuntimePendingUse( + ulong Token, + uint ServerGuid, + bool OwnedByPlayer, + bool Useable, + ItemUseRequestReservation? Reservation, + RuntimeInteractionApproachToken ApproachToken); + public readonly record struct RuntimeAppraisalResponseAcceptance( bool Accepted, bool FirstResponse); @@ -53,7 +70,13 @@ public readonly record struct RuntimeInteractionTransactionSnapshot( int OutboundCount, bool HasPendingPickup, ulong PendingPickupToken, - long DispatchFailureCount) + long DispatchFailureCount, + // G3 (grand-gate finding): an armed out-of-range Use holds a live + // ItemUseRequestReservation (a busy-count reference) until arrival or + // cancellation resolves it — it must reach zero at teardown exactly + // like HasPendingPickup. + bool HasPendingUse = false, + ulong PendingUseToken = 0u) { public bool IsConverged => IsDisposed @@ -62,7 +85,8 @@ public readonly record struct RuntimeInteractionTransactionSnapshot( && AwaitingAppraisalId == 0u && CurrentAppraisalId == 0u && OutboundCount == 0 - && !HasPendingPickup; + && !HasPendingPickup + && !HasPendingUse; } /// @@ -71,10 +95,23 @@ public readonly record struct RuntimeInteractionTransactionSnapshot( /// and is borrowed exactly. /// /// -/// Ordinary Use follows ItemHolder::UseObject @ 0x00588A80: consume the -/// strict 0.2-second gate, send immediately, then transfer the busy reference -/// to ClientUISystem::Handle_Item__UseDone @ 0x00564900. Pickup keeps -/// the existing local approach transaction and exact post-arrival token. +/// Ordinary (already-in-range) Use follows ItemHolder::UseObject @ +/// 0x00588A80: consume the strict 0.2-second gate, send immediately, +/// then transfer the busy reference to ClientUISystem::Handle_Item__UseDone +/// @ 0x00564900. Pickup keeps the existing local approach transaction and +/// exact post-arrival token. +/// +/// G3 (grand-gate finding, 2026-08-08): an OUT-OF-RANGE Use does NOT send +/// immediately — ACE's Player.HandleActionUseItem +/// (Player_Use.cs:176-215) only calls ActOnUse once its own +/// CreateMoveToChain confirms the player is within the target's use +/// radius; a Use that arrives while still out of range never opens the +/// vendor panel (see register AP-170). / +/// mirror +/// 's exact arrival-gated shape for this +/// case; an already-in-range Use is unaffected and still dispatches via +/// immediately. +/// /// public sealed class RuntimeInteractionTransactionState : IDisposable { @@ -89,6 +126,8 @@ public sealed class RuntimeInteractionTransactionState : IDisposable private uint _currentAppraisalId; private RuntimePendingPickup? _pendingPickup; private ulong _nextPickupToken; + private RuntimePendingUse? _pendingUse; + private ulong _nextUseToken; private uint _clearEpoch; private long _revision; private long _dispatchFailureCount; @@ -106,6 +145,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable public uint CurrentAppraisalId => _currentAppraisalId; public int OutboundCount => _outbound.Count; public bool HasPendingPickup => _pendingPickup is not null; + public bool HasPendingUse => _pendingUse is not null; public bool IsDisposed => _disposed; public long Revision => Interlocked.Read(ref _revision); public long DispatchFailureCount => @@ -122,7 +162,9 @@ public sealed class RuntimeInteractionTransactionState : IDisposable _outbound.Count, _pendingPickup is not null, _pendingPickup?.Token ?? 0u, - DispatchFailureCount); + DispatchFailureCount, + _pendingUse is not null, + _pendingUse?.Token ?? 0u); public bool TryConsumeUseThrottle(long nowMs) { @@ -517,6 +559,124 @@ public sealed class RuntimeInteractionTransactionState : IDisposable return dispatched; } + /// + /// G3: arms an out-of-range Use to dispatch once the approach completes + /// naturally, mirroring . The + /// (if any) crosses the approach boundary + /// unresolved — it is released only by + /// (via the caller's + /// dispatch/cancel), , + /// or a reset/dispose, never here. + /// + public bool TryArmPostArrivalUse( + uint serverGuid, + bool ownedByPlayer, + bool useable, + ItemUseRequestReservation? reservation, + RuntimeInteractionApproachToken approachToken, + out RuntimePendingUse pending) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (serverGuid == 0u + || approachToken.ControllerLifetime == 0u + || approachToken.ApproachGeneration == 0u) + { + pending = default; + return false; + } + if (_pendingUse is not null) + { + pending = default; + return false; + } + + ulong token = ++_nextUseToken; + if (token == 0u) + token = ++_nextUseToken; + pending = new RuntimePendingUse( + token, + serverGuid, + ownedByPlayer, + useable, + reservation, + approachToken); + _pendingUse = pending; + IncrementRevision(); + return true; + } + + /// + /// G3: resolves an armed Use's approach completion, mirroring + /// . The caller is responsible + /// for dispatching (via , which itself + /// resolves ) or cancelling + /// () + /// depending on the returned bool and whether the target is still + /// current. + /// + public bool TryResolveUseApproachCompletion( + RuntimeInteractionApproachToken approachToken, + bool natural, + out RuntimePendingUse pending) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_pendingUse is not { } current + || current.ApproachToken != approachToken) + { + pending = default; + return false; + } + + _pendingUse = null; + pending = current; + IncrementRevision(); + return natural; + } + + public bool TryGetPendingUse(out RuntimePendingUse pending) + { + if (_pendingUse is { } current) + { + pending = current; + return true; + } + pending = default; + return false; + } + + /// Unconditional cancel — used when a NEW approach supersedes whatever was armed. + public bool TryCancelPendingUse(out RuntimePendingUse pending) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_pendingUse is not { } current) + { + pending = default; + return false; + } + _pendingUse = null; + pending = current; + IncrementRevision(); + return true; + } + + /// Guid-matching cancel — used when the target itself vanishes (hidden/removed). + public bool TryCancelPendingUse( + uint serverGuid, + out RuntimePendingUse pending) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_pendingUse is not { } current + || current.ServerGuid != serverGuid) + { + pending = default; + return false; + } + _pendingUse = null; + pending = current; + IncrementRevision(); + return true; + } + public void ResetSession() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -538,16 +698,25 @@ public sealed class RuntimeInteractionTransactionState : IDisposable || _currentAppraisalId != 0u || _outbound.Count != 0 || _pendingPickup is not null + || _pendingUse is not null || _lastUseSourceId != 0u || _lastUseTargetId != 0u || _lastUseMs != long.MinValue / 2; + // G3: an armed Use's reservation is a live busy-count reference — + // release it here unconditionally so a reset/dispose that runs + // without a preceding CancelPendingApproach() (e.g. a headless/ + // no-window teardown with no SelectionInteractionController) can + // never leak it. Idempotent: a no-op if already resolved. + _pendingUse?.Reservation?.CancelBeforeDispatch(); + _lastUseSourceId = 0u; _lastUseTargetId = 0u; _awaitingAppraisalId = 0u; _currentAppraisalId = 0u; _outbound.Clear(); _pendingPickup = null; + _pendingUse = null; _lastUseMs = long.MinValue / 2; _clearEpoch++; if (resetInventory) diff --git a/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs b/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs index 2177bfc4..caee3b18 100644 --- a/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs +++ b/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs @@ -224,15 +224,47 @@ public sealed class VendorShopItemMaterializer : IDisposable /// /// Field mapping from the domain-shaped to /// the wire-shaped merge patch - /// expects. — not - /// , ItemProfile's separate packed - /// SUPPLY-count field — is the wire equivalent of an ordinary - /// CreateObject's own StackSize field (see the doc comment on - /// ). Every field - /// doesn't carry (capacity, equip mask, - /// combat use, etc.) is passed null, leaving it untouched on a refresh - /// and defaulted on a fresh object per 's - /// null-preserving merge contract. + /// expects. + /// + /// G2 gate-finding fix (2026-08-08, register AP-169): retail's own + /// client (gmToolbarUI::HandleSelectionChanged, pc:198688/ + /// 198744/198774/198791) reads + /// eax_5->pwd._stackSize — our + /// — uniformly for BOTH owned-inventory and vendor-owned selections to + /// decide whether the toolbar split slider shows and what it caps at. + /// The user's local ACE server never carries that value for a browse- + /// list row: Vendor.LoadInventoryItem + /// (references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:144-172) + /// builds the listing WorldObject via + /// WorldObjectFactory.CreateNewWorldObject and sets ONLY + /// wo.VendorShopCreateListStackSize (our + /// , the packed "how many for + /// sale" ItemProfile dword) — it never calls wo.SetStackSize(...), + /// so PublicWeenieDesc's own conditional StackSize field + /// (walked by GameEventApproachVendor.cs:60's + /// obj.SerializeGameDataOnly) comes back null on the real wire + /// for every vendor listing. Reading only DescStackSize here + /// therefore left at its default + /// for every materialized shop item, so the toolbar split slider never + /// appeared for ANY vendor stack, matching the live report exactly (it + /// DID appear for owned-inventory stacks, which ACE populates normally + /// via ordinary pickup/loot SetStackSize calls). This now prefers + /// DescStackSize when present (retail-faithful first, and + /// forward-compatible with any server that DOES populate it), falling + /// back to the packed StackSize supply-count field clamped to a + /// sane positive bound — the field that IS reliably populated against + /// ACE. The StackSize == -1 (unlimited-supply) sentinel has no + /// bounded per-row purchase cap in 's wire + /// shape today (no _maxStackSize field carried), so it falls + /// through to the conservative "1" default rather than inventing an + /// arbitrary ceiling — see the register. + /// + /// + /// Every other field doesn't carry + /// (capacity, equip mask, combat use, etc.) is passed null, leaving it + /// untouched on a refresh and defaulted on a fresh object per + /// 's null-preserving merge contract. + /// /// private static WeenieData ToWeenieData(VendorShopItem item, uint vendorId) => new( Guid: item.ItemGuid, @@ -244,7 +276,7 @@ public sealed class VendorShopItemMaterializer : IDisposable IconUnderlayId: item.IconUnderlayId, Effects: item.Effects, Value: item.Value, - StackSize: item.DescStackSize, + StackSize: ResolveDisplayStackSize(item), StackSizeMax: null, Burden: null, ContainerId: vendorId, @@ -259,6 +291,23 @@ public sealed class VendorShopItemMaterializer : IDisposable Workmanship: null, PluralName: item.PluralName); + /// + /// G2 fix: when the wire + /// actually carried it (nonzero — a genuinely retail-faithful server), + /// else the packed supply count + /// (what ACE reliably sends) when it names a real bounded quantity, + /// else 1 (non-splittable — the safe default for the unlimited-supply + /// sentinel or a genuinely single-unit listing). + /// + private static int? ResolveDisplayStackSize(VendorShopItem item) + { + if (item.DescStackSize is { } desc && desc > 0) + return desc; + if (item.StackSize > 0) + return item.StackSize; + return 1; + } + public void Dispose() { if (_disposed) return; diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index e4d9d9a7..5e66d0cb 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -457,17 +457,25 @@ public sealed class SelectionInteractionControllerTests /// /// C1 (Slice 6b move-to-use, docs/research/2026-08-08-slice6b-vendor- - /// completion-research.md Q2): an out-of-range Use kicks off the SAME - /// local client-predicted MoveToObject approach Pickup's far-range - /// branch already installs, giving the walk immediate visual feel. The - /// wire send is never gated on arrival — retail's - /// ItemHolder::UseObject @ 0x00588A80 has no range check and - /// sends unconditionally, so the dispatch and the approach both happen - /// at click time, in that order. A later natural MoveTo completion must - /// not re-dispatch (Use has no post-arrival token the way Pickup does). + /// completion-research.md Q2, REVISED for G3 — grand-gate finding + /// 2026-08-08, register AP-170): an out-of-range Use kicks off the SAME + /// local client-predicted MoveToObject approach Pickup's close-range + /// branch already arms on, giving the walk immediate visual feel. Unlike + /// the original (send-immediately) design, the wire send is now GATED on + /// natural arrival — live testing against the user's local ACE server + /// showed retail's own "no range check, send immediately" assumption + /// does not hold there: ACE's Player.HandleActionUseItem polls + /// for the player to actually reach use range before calling + /// ActOnUse, and a Use that arrives too early is silently lost + /// (the vendor's cosmetic greeting fires — a distance-only reaction + /// independent of Use — but ApproachVendor never comes). Nothing + /// dispatches until + /// fires; a SECOND natural-completion call must not re-dispatch (the + /// pending-use token is consumed on first resolve, exactly like Pickup's + /// pending-pickup token). /// [Fact] - public void FarUseApproachesThenDispatchesImmediatelyAndDoesNotRetryOnArrival() + public void FarUseApproachesThenDispatchesOnNaturalArrival() { var h = new Harness(); h.SetApproach(closeRange: false); @@ -475,6 +483,11 @@ public sealed class SelectionInteractionControllerTests h.Controller.SendUse(Target); PlayerInteractionMovementSinkAssertSingleApproach(h, Target); + // Armed, not yet sent — the whole point of the fix. + Assert.Empty(h.Transport.Uses); + + h.Controller.OnNaturalMoveToComplete(); + Assert.Equal(new[] { Target }, h.Transport.Uses); h.Controller.OnNaturalMoveToComplete(); @@ -483,10 +496,10 @@ public sealed class SelectionInteractionControllerTests } /// - /// C1 cancellation coverage: a second far Use command (the player picked - /// a new target, i.e. "moved on") supersedes the first local approach - /// cleanly — no exception, no missing/duplicated dispatch, no leaked - /// pending-pickup state (Use never arms one). + /// C1/G3 cancellation coverage: a second far Use command (the player + /// picked a new target, i.e. "moved on") supersedes the first local + /// approach cleanly — the FIRST target's armed Use is cancelled (never + /// sent), and only the SECOND dispatches, on ITS OWN natural arrival. /// [Fact] public void NewFarUseCommandSupersedesThePreviousApproachCleanly() @@ -509,23 +522,25 @@ public sealed class SelectionInteractionControllerTests Assert.Equal(2, h.Movement.Approaches.Count); Assert.Equal(Target, h.Movement.Approaches[0].Target.ServerGuid); Assert.Equal(otherTarget, h.Movement.Approaches[1].Target.ServerGuid); - Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses); + // Neither has sent yet — both are armed/superseded, not dispatched. + Assert.Empty(h.Transport.Uses); h.Controller.OnNaturalMoveToComplete(); - Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses); + // Only the surviving (second) approach's Use goes out. + Assert.Equal(new[] { otherTarget }, h.Transport.Uses); } /// - /// C1 cancellation coverage: the underlying MoveTo controller cancelling - /// out from under a far Use's local approach (player moved away with - /// WASD, or any other source of ) must not - /// retract or duplicate the Use, which already went out unconditionally - /// at click time — Use holds no pending-pickup state for - /// OnMoveToCancelled to touch. + /// C1/G3 cancellation coverage: the underlying MoveTo controller + /// cancelling out from under a far Use's local approach (player moved + /// away with WASD, or any other source of ) + /// must cancel the ARMED (not-yet-sent) Use — retail's own server-side + /// poll would never have seen the player arrive either, so nothing + /// should reach the wire. /// [Fact] - public void MovingAwayDuringAFarUseApproachDoesNotAffectTheAlreadyDispatchedUse() + public void MovingAwayDuringAFarUseApproachCancelsTheArmedUse() { var h = new Harness(); h.SetApproach(closeRange: false); @@ -534,7 +549,7 @@ public sealed class SelectionInteractionControllerTests h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled); h.Controller.OnNaturalMoveToComplete(); - Assert.Equal(new[] { Target }, h.Transport.Uses); + Assert.Empty(h.Transport.Uses); } private static void PlayerInteractionMovementSinkAssertSingleApproach( diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs index b9b40859..46c26d3e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs @@ -673,6 +673,102 @@ public class SelectedObjectControllerTests // fails, and the failure is what gets fixed). // ══════════════════════════════════════════════════════════════════════ + /// + /// G2 (grand-gate finding): C4 passed by manually hand-setting + /// ClientObject.StackSize directly, bypassing BOTH the real + /// that populates it from a + /// live ApproachVendor snapshot AND the real + /// ObjectUpdated/ObjectAdded event wiring. This test drives + /// the same scenario through the REAL materializer — + /// fires , the REAL + /// (subscribed exactly like + /// production's RuntimeInventoryState constructor) ingests the + /// shop item into the SAME , and only + /// THEN is the item selected — closing the gap the C4 harness left open. + /// + [Fact] + public void G2_VendorStackSelection_ThroughRealMaterializer_ShowsSplitSlider() + { + const uint vendorGuid = 0x70000011u; + const uint arrowsGuid = 0x60009011u; + + ImportedLayout layout = FixtureLoader.LoadToolbar(); + var objects = new ClientObjectTable(); + var vendor = new VendorState(); + var selection = new SelectionState(); + var splitQuantity = new StackSplitQuantityState(); + + // REAL materializer, wired exactly like RuntimeInventoryState's + // constructor (RuntimeInventoryState.cs:74): + // VendorItems = new VendorShopItemMaterializer(Vendor, _entityObjects.Objects); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + + SelectedObjectController controller = SelectedObjectController.Bind( + layout, + selection, + subscribeHealthChanged: _ => { }, + unsubscribeHealthChanged: _ => { }, + subscribeItemManaChanged: _ => { }, + unsubscribeItemManaChanged: _ => { }, + isHealthTarget: _ => false, + isOwnedByPlayer: _ => false, + name: guid => objects.Get(guid)?.GetAppropriateName(), + healthPercent: _ => 0f, + hasHealth: _ => false, + stackSize: guid => (uint)(objects.Get(guid)?.StackSize ?? 0), + sendQueryHealth: _ => { }, + manaPercent: _ => 0f, + sendQueryItemMana: _ => { }, + datFont: null, + splitQuantity: splitQuantity, + // REAL subscription this time (C4 used no-op lambdas here). + subscribeObjectUpdated: h => objects.ObjectUpdated += h, + unsubscribeObjectUpdated: h => objects.ObjectUpdated -= h, + isVendorSplitExempt: guid => + vendor.VendorId != 0u + && objects.Get(guid) is { } vendorCandidate + && vendorCandidate.ContainerId == vendor.VendorId + && VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)); + + // G2 root cause: a REAL ACE vendor listing, byte-for-byte. ACE's + // Vendor.LoadInventoryItem (Vendor.cs:144-172) builds the browse-list + // WorldObject via WorldObjectFactory.CreateNewWorldObject and sets + // ONLY wo.VendorShopCreateListStackSize = stackSize ?? -1 (the "how + // many available" packed dword — our VendorShopItem.StackSize) — + // it NEVER calls wo.SetStackSize(...), so the per-item + // PublicWeenieDesc's own conditional StackSize field (our + // DescStackSize — retail's pwd._stackSize, what + // GameEventApproachVendor.cs:60's SerializeGameDataOnly walks) comes + // back null on the real wire. StackSize=100 (packed "100 for sale"), + // DescStackSize=null (ACE never sets it) is what a real vendor + // listing looks like, NOT the DescStackSize=100 shape used above. + vendor.Apply( + vendorGuid, + new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""), + new[] + { + new VendorShopItem( + arrowsGuid, StackSize: 100, WeenieClassId: 5u, Name: "Arrow", + ItemType: (uint)ItemType.MissileWeapon, IconId: 200u, Value: 100, + DescStackSize: null, PluralName: "Arrows"), + }); + + // The item is now materialized (ClientObjectTable.Ingest ran inside + // VendorState.Apply's Changed dispatch) BEFORE selection, exactly + // like a real click on VendorUiController's item list. + Assert.NotNull(objects.Get(arrowsGuid)); + Assert.Equal(100, objects.Get(arrowsGuid)!.StackSize); + + selection.Select(arrowsGuid, SelectionChangeSource.Vendor); + + var slider = Assert.IsType( + layout.FindElement(SelectedObjectController.StackSizeSliderId)); + Assert.True(slider.Visible); + Assert.Equal(100u, splitQuantity.Maximum); + + controller.Dispose(); + } + [Fact] public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation() { diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index b8434874..d987be34 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -1636,6 +1636,98 @@ public sealed class VendorUiControllerTests Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages); } + /// + /// G1 (vendor gate finding): live testing showed "Buy All" false-blocking + /// a container purchase while the player visibly had free container + /// slots ("3 left"). Root cause: CountPlayerContents classified + /// "is this occupied slot a container" from the item's own + /// bit OR nonzero + /// ItemsCapacity/ContainersCapacity — a LOCAL heuristic — + /// instead of retail's actual wire-carried classification + /// (ContainerProperties, threaded onto + /// by every membership + /// path). A non-Container-typed object with a stray nonzero capacity + /// field (and a wire hint of None) was over-counted as an + /// occupied CONTAINER slot, shrinking the free-slot count below the + /// real one and false-blocking a purchase the player had room for. + /// This test pins a player pack with one such object (armor, hint=None, + /// but ItemsCapacity happens to read nonzero) plus 6 free container + /// slots out of 7 — buying ONE more container must succeed. + /// + [Fact] + public void BuyAllButton_StrayCapacityFieldOnNonContainerItem_DoesNotFalseBlockWithFreeSlots() + { + var h = new Harness(); + // AddOrUpdate FIRST (creates the object carrying the stray capacity + // field), InitializeInventoryManifest SECOND (updates the SAME + // object's placement/hint in place — it does not touch Type/ + // ItemsCapacity, matching AddOrUpdate's own doc comment: "does NOT + // update the container index"). + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x60002001u, + Type = ItemType.Armor, + ItemsCapacity = 3, + }); + h.Objects.InitializeInventoryManifest(Harness.PlayerGuid, new[] + { + // Wire truth (ContainerType=0/None): this is NOT a container. + // Its Type is Armor (not Container) but it carries a stray + // nonzero ItemsCapacity — the old heuristic misread that as + // "occupies a container slot." + new ContainerContentEntry(0x60002001u, 0u), + }); + // Only 1 container slot total so a miscount of this ONE stray item + // as a container (containersUsed 1 instead of 0) actually flips the + // guard, instead of being absorbed by the harness's 7-slot default. + h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 1; + + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100), + }); + h.AddButton.OnClick!.Invoke(); + + h.BuyAllButton.OnClick!.Invoke(); + + // 1 capacity - 0 REAL containers used = 1 free -> buying 1 succeeds. + Assert.Single(h.BuyAlls); + Assert.Empty(h.SystemMessages); + } + + /// + /// G1 companion: a REAL side-pack (wire hint ContainerType=1/Container, + /// no ItemType.Container bit and no capacity fields populated — e.g. a + /// container object seen only via a membership manifest, not its own + /// full CreateObject) still correctly consumes a container slot. Proves + /// the fix's hint-primary classification isn't just permissive by + /// omission — it still catches a real container the OLD Type-bit-only + /// fallback would have missed too. + /// + [Fact] + public void BuyAllButton_HintOnlyContainer_StillCountsAgainstContainerCapacity() + { + var h = new Harness(); + h.Objects.InitializeInventoryManifest(Harness.PlayerGuid, new[] + { + new ContainerContentEntry(0x60002010u, 1u), // Container, hint-only + }); + + 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 = 1; + + h.BuyAllButton.OnClick!.Invoke(); + + // Capacity 1, 1 REAL container already used (via hint) -> 0 free, + // buying 1 more must block. + Assert.Empty(h.BuyAlls); + Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages); + } + [Fact] public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess() { diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index b87ae287..4d06dec1 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using AcDream.Core.Items; using Xunit; @@ -765,6 +766,208 @@ public sealed class ClientObjectTableTests Assert.Equal(new[] { item }, table.GetContents(pack)); } + /// + /// SCRATCH G4 reproduction: a vendor BUY's real wire shape is a fresh + /// CreateObject (a BRAND NEW guid our client has never seen before — + /// ACE's Player_Commerce.cs FinalizeBuyTransaction/ItemProfileToWorldObjects + /// mints a new WorldObject for common stock, distinct from the shop-list + /// guid) carrying ContainerId=player already (TryAddToInventory sets it + /// before TryCreateInInventoryWithNetworking sends CreateObject), followed + /// by the SAME InventoryPutObjInContainer (0x0022) placement=0 message a + /// pickup gets. This drives that EXACT two-message sequence — Ingest + /// (simulating CreateObject) THEN ApplyConfirmedServerMove (simulating + /// the 0x0022 echo) — against a pack that already has other items, to see + /// whether the fresh-guid case actually prepends the way + /// AuthoritativePickup_PlacementZeroInsertsAtRetailListHead's + /// already-known-guid case (built via ReplaceContents, which creates the + /// object FIRST) already proves. + /// + [Fact] + public void SCRATCH_BuyShapedFreshGuidCreateObjectThenContainId_FinalOrder() + { + var table = new ClientObjectTable(); + const uint pack = 0x50000001u; + const uint existingA = 0xA01u; + const uint existingB = 0xA02u; + const uint boughtItem = 0xB01u; + + table.InitializeInventoryManifest(pack, new[] + { + new ContainerContentEntry(existingA, 0u), + new ContainerContentEntry(existingB, 0u), + }); + + // Simulates the buy's CreateObject: a guid NEVER SEEN BEFORE, + // already carrying ContainerId=pack (matching TryAddToInventory's + // pre-send assignment), routed through the SAME ApplyEntitySpawn -> + // Ingest path production CreateObject handling uses. + table.Ingest(new WeenieData( + Guid: boughtItem, + Name: "Arrow", + Type: ItemType.MissileWeapon, + WeenieClassId: 5u, + IconId: 0u, + IconOverlayId: 0u, + IconUnderlayId: 0u, + Effects: 0u, + Value: 100, + StackSize: 100, + StackSizeMax: null, + Burden: 1, + ContainerId: pack, + WielderId: 0u, + ValidLocations: null, + CurrentWieldedLocation: null, + Priority: null, + ItemsCapacity: null, + ContainersCapacity: null, + Structure: null, + MaxStructure: null, + Workmanship: null)); + + // Simulates the SAME wire echo a pickup gets (0x0022, + // InventoryPutObjInContainer / Item_ServerSaysContainId), which ACE + // sends unconditionally after TryAddToInventory for a buy exactly + // the same way it does for a pickup — with Placement=0. + Assert.True(table.ApplyConfirmedServerMove( + boughtItem, + pack, + newWielderId: 0u, + newSlot: 0, + containerTypeHint: 0u)); + + Assert.Equal( + new[] { boughtItem, existingA, existingB }, + table.GetContents(pack)); + } + + /// + /// G4 (grand-gate finding): ACE's GameMessageCreateObject rides + /// GameMessageGroup.SmartboxQueue + /// (references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageCreateObject.cs:8) + /// while GameEventItemServerSaysContainId (our + /// InventoryPutObjInContainer, 0x0022) rides + /// GameMessageGroup.UIQueue — TWO independent reliable queues + /// with no cross-queue ordering guarantee. A vendor buy of common stock + /// mints a brand-new guid (ACE's ItemProfileToWorldObjects) the + /// client has never seen, so unlike an ordinary pickup (whose item was + /// already visible/known), its final resting slot depends entirely on + /// which queue's message the client happens to process first. This + /// drives the 0x0022 echo BEFORE its item's CreateObject — root cause, + /// live-verified: before the fix this returned applied=false and + /// silently dropped the placement, leaving the item appended at the + /// list tail once its later CreateObject-only Ingest ran. + /// + [Fact] + public void ContainIdArrivingBeforeCreateObject_StillInsertsAtRetailListHead() + { + var table = new ClientObjectTable(); + const uint pack = 0x50000001u; + const uint existingA = 0xA01u; + const uint existingB = 0xA02u; + const uint boughtItem = 0xB02u; + + table.InitializeInventoryManifest(pack, new[] + { + new ContainerContentEntry(existingA, 0u), + new ContainerContentEntry(existingB, 0u), + }); + + // The 0x0022 echo arrives FIRST — boughtItem does not exist in the + // table yet. ApplyConfirmedServerMove itself still reports failure + // (nothing to move YET) — the fix is that it no longer drops the + // request on the floor. + Assert.False(table.ApplyConfirmedServerMove( + boughtItem, + pack, + newWielderId: 0u, + newSlot: 0, + containerTypeHint: 0u)); + + // THEN the CreateObject arrives. + table.Ingest(new WeenieData( + Guid: boughtItem, + Name: "Arrow", + Type: ItemType.MissileWeapon, + WeenieClassId: 5u, + IconId: 0u, + IconOverlayId: 0u, + IconUnderlayId: 0u, + Effects: 0u, + Value: 100, + StackSize: 100, + StackSizeMax: null, + Burden: 1, + ContainerId: pack, + WielderId: 0u, + ValidLocations: null, + CurrentWieldedLocation: null, + Priority: null, + ItemsCapacity: null, + ContainersCapacity: null, + Structure: null, + MaxStructure: null, + Workmanship: null)); + + // The stashed placement replays: the bought item lands at the head + // (retail slot 0), matching the in-order case exactly. + Assert.Equal( + new[] { boughtItem, existingA, existingB }, + table.GetContents(pack)); + Assert.Equal(pack, table.Get(boughtItem)!.ContainerId); + Assert.Equal(0u, table.Get(boughtItem)!.ContainerTypeHint); + } + + /// + /// G4 companion: a stashed placement for a guid that never actually + /// arrives (e.g. a stale/unrelated echo) must not leak forever, or + /// silently resurrect a placement for some LATER, unrelated recycled + /// guid. (session teardown) drops + /// it. + /// + [Fact] + public void PendingUnresolvedPlacement_IsDroppedByClear() + { + var table = new ClientObjectTable(); + const uint pack = 0x50000001u; + const uint neverArrives = 0xB03u; + + Assert.False(table.ApplyConfirmedServerMove( + neverArrives, pack, newWielderId: 0u, newSlot: 0, containerTypeHint: 0u)); + + table.Clear(); + + // A later, unrelated Ingest of the SAME (recycled) guid must not + // resurrect the old stashed placement — it should append normally + // (no other contents to reposition ahead of, so this just proves no + // exception/misplacement occurs after a session boundary). + table.Ingest(new WeenieData( + Guid: neverArrives, + Name: "Something Else", + Type: ItemType.Misc, + WeenieClassId: 9u, + IconId: 0u, + IconOverlayId: 0u, + IconUnderlayId: 0u, + Effects: 0u, + Value: 1, + StackSize: null, + StackSizeMax: null, + Burden: 1, + ContainerId: pack, + WielderId: 0u, + ValidLocations: null, + CurrentWieldedLocation: null, + Priority: null, + ItemsCapacity: null, + ContainersCapacity: null, + Structure: null, + MaxStructure: null, + Workmanship: null)); + + Assert.Equal(new[] { neverArrives }, table.GetContents(pack)); + } + [Fact] public void AuthoritativePickup_PlacementZeroInsertsAtRetailListHead() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInteractionTransactionStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInteractionTransactionStateTests.cs index a978d559..68cfdd8d 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInteractionTransactionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInteractionTransactionStateTests.cs @@ -378,6 +378,143 @@ public sealed class RuntimeInteractionTransactionStateTests state.Dispose(); } + // ══════════════════════════════════════════════════════════════════ + // G3 (grand-gate finding) — arrival-gated Use, mirroring the pickup + // coverage above. + // ══════════════════════════════════════════════════════════════════ + + [Fact] + public void PostArrivalUseRequiresItsExactApproachTokenAndDispatchesTheHeldReservation() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + ItemUseRequestReservation reservation = state.BeginUseRequestReservation(); + var approach = new RuntimeInteractionApproachToken(3u, 7u); + + Assert.True(state.TryArmPostArrivalUse( + Item, + ownedByPlayer: false, + useable: true, + reservation, + approach, + out RuntimePendingUse pending)); + Assert.True(state.HasPendingUse); + // Nothing sent yet — armed, not dispatched. + Assert.Equal(1, inventory.BusyCount); + + // The wrong token must not resolve it. + Assert.False(state.TryResolveUseApproachCompletion( + new RuntimeInteractionApproachToken(3u, 8u), + natural: true, + out _)); + Assert.True(state.HasPendingUse); + + Assert.True(state.TryResolveUseApproachCompletion( + approach, + natural: true, + out RuntimePendingUse ready)); + Assert.Equal(pending.Token, ready.Token); + Assert.False(state.HasPendingUse); + + var transport = new Transport(); + RuntimeInteractionDispatchResult result = state.TryDispatchUse( + ready.ServerGuid, + ready.OwnedByPlayer, + ready.Useable, + ready.Reservation, + transport, + out uint sequence); + + Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result); + Assert.Equal(1u, sequence); + Assert.Equal(new[] { Item }, transport.Uses); + // The reservation transferred to the authoritative UseDone wait, + // exactly like an ordinary immediate Use. + Assert.Equal(1, inventory.BusyCount); + } + + [Fact] + public void UseApproachCompletionCancellationReleasesTheHeldReservationWithoutSending() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + ItemUseRequestReservation reservation = state.BeginUseRequestReservation(); + var approach = new RuntimeInteractionApproachToken(1u, 1u); + Assert.True(state.TryArmPostArrivalUse( + Item, ownedByPlayer: false, useable: true, reservation, approach, out _)); + Assert.Equal(1, inventory.BusyCount); + + // natural: false (cancellation, e.g. moved away) — TryResolveUseApproachCompletion + // itself returns false; the caller is responsible for releasing the + // reservation (mirrors SelectionInteractionController.HandleUseApproachCompletion). + Assert.False(state.TryResolveUseApproachCompletion( + approach, natural: false, out RuntimePendingUse cancelled)); + Assert.False(state.HasPendingUse); + cancelled.Reservation?.CancelBeforeDispatch(); + + Assert.Equal(0, inventory.BusyCount); + } + + [Fact] + public void CancelPendingUseByGuidOnlyMatchesTheArmedTarget() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + ItemUseRequestReservation reservation = state.BeginUseRequestReservation(); + Assert.True(state.TryArmPostArrivalUse( + Item, ownedByPlayer: false, useable: true, reservation, + new RuntimeInteractionApproachToken(1u, 1u), out _)); + + Assert.False(state.TryCancelPendingUse(Container, out _)); + Assert.True(state.HasPendingUse); + + Assert.True(state.TryCancelPendingUse(Item, out RuntimePendingUse cancelled)); + Assert.False(state.HasPendingUse); + cancelled.Reservation?.CancelBeforeDispatch(); + Assert.Equal(0, inventory.BusyCount); + } + + [Fact] + public void ArmingASecondUseWhileOneIsAlreadyArmedFails() + { + using var inventory = NewInventory(out _); + using var state = new RuntimeInteractionTransactionState(inventory); + Assert.True(state.TryArmPostArrivalUse( + Item, ownedByPlayer: false, useable: true, reservation: null, + new RuntimeInteractionApproachToken(1u, 1u), out _)); + + Assert.False(state.TryArmPostArrivalUse( + Container, ownedByPlayer: false, useable: true, reservation: null, + new RuntimeInteractionApproachToken(2u, 2u), out _)); + Assert.Equal(Item, state.TryGetPendingUse(out RuntimePendingUse stillArmed) + ? stillArmed.ServerGuid + : 0u); + } + + [Fact] + public void PendingUseReservationIsReleasedByResetSessionAndDisposal() + { + using var inventory = NewInventory(out _); + var state = new RuntimeInteractionTransactionState(inventory); + ItemUseRequestReservation reservation = state.BeginUseRequestReservation(); + Assert.True(state.TryArmPostArrivalUse( + Item, ownedByPlayer: false, useable: true, reservation, + new RuntimeInteractionApproachToken(1u, 1u), out _)); + Assert.Equal(1, inventory.BusyCount); + + state.ResetSession(); + + // G3: ResetCore releases a live pending-use reservation even when no + // caller explicitly cancelled it first — must not leak a busy-count + // reference. + Assert.Equal(0, inventory.BusyCount); + Assert.False(state.HasPendingUse); + + state.Dispose(); + Assert.True(state.CaptureOwnership().IsConverged); + state.Dispose(); + } + [Fact] public void InstancesDoNotShareThrottleQueueAppraisalOrPickupState() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs index 1f943189..7fe67fe6 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/VendorShopItemMaterializerTests.cs @@ -116,6 +116,58 @@ public sealed class VendorShopItemMaterializerTests Assert.Equal(5, objects.Get(ItemA)!.StackSize); } + /// + /// G2 (grand-gate finding): a REAL ACE vendor listing carries + /// DescStackSize=null (ACE's Vendor.LoadInventoryItem never + /// calls wo.SetStackSize on the browse-list WorldObject) and only + /// the packed supply-count field + /// (VendorShopCreateListStackSize) names a real quantity. Before + /// the fix, ToWeenieData read only DescStackSize, so + /// came back 1 for every vendor + /// item — the toolbar split slider (which gates on + /// stackSize > 1) never appeared for ANY vendor stack. This + /// pins the fallback: no DescStackSize, packed + /// StackSize=100 -> ClientObject.StackSize resolves to + /// 100, not 1. + /// + [Fact] + public void Apply_NoDescStackSize_FallsBackToPackedSupplyCount() + { + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + + vendor.Apply(VendorGuid, default, new[] + { + new VendorShopItem( + ItemA, StackSize: 100, WeenieClassId: 1u, Name: "Arrow", + ItemType: (uint)ItemType.MissileWeapon, IconId: 0x1234u, Value: 100, + DescStackSize: null), + }); + + Assert.Equal(100, objects.Get(ItemA)!.StackSize); + } + + /// + /// G2 companion: the packed field's -1 "unlimited supply" sentinel has + /// no bounded per-row purchase cap in the wire shape today, so it must + /// fall through to the safe non-splittable default (1) rather than + /// literally propagating -1 (which would read as a huge unsigned + /// "stack size" to 's + /// stackSize > 1 gate). + /// + [Fact] + public void Apply_UnlimitedSupplySentinel_FallsBackToNonSplittableDefault() + { + var vendor = new VendorState(); + var objects = new ClientObjectTable(); + using var materializer = new VendorShopItemMaterializer(vendor, objects); + + vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Bread") }); // StackSize: -1, DescStackSize: null + + Assert.Equal(1, objects.Get(ItemA)!.StackSize); + } + [Fact] public void Refreshed_ItemNoLongerListed_IsRemoved() {