diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index b7f4c631..56f08218 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -169,7 +169,7 @@ readiness/requeue adaptation. See
---
-## 3. Documented approximation (AP) — 110 active rows (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) — 111 active rows (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
@@ -177,6 +177,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
+| AP-160 | **Filed 2026-08-07, Slice 5.3 (vendor browse lifecycle).** The client-local vendor-panel distance watcher closes on PLAIN 3D center-to-center distance instead of retail/ACE's CYLINDER-GAP distance (both objects' own collision radius and height subtracted from the center distance before comparing to `UseRadius`). Retail: `gmVendorUI::OpenVendor` registers `CPlayerSystem::RegisterObjectRangeHandler` keyed to the vendor's own `PublicWeenieDesc._useRadius`; ACE's server-side belt-and-suspenders `Vendor.CheckClose` closes on `GetCylinderDistance(lastPlayer) > UseRadius`, i.e. `Position::cylinder_distance`/`Physics.Common.Position.CylinderDistance` with each side's real `GetRadius()`/`GetHeight()`. | `src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs` (`EnforceRange`) | `AcDream.Runtime` does not resolve a live per-entity collision radius/height for an arbitrary NPC outside the App-layer's Setup-cylinder resolver (`WorldSelectionQuery`'s `_setupCylinder`, App-only — out of Runtime's reach per the Core-structure rules, and `PhysicsBody`/`RuntimeEntityRecord` carry no radius/height field). Plain center distance is a well-defined, non-degenerate substitute (using `ObjectRangeMath.ObjectsInRange`'s existing `useRadii: false` branch rather than inventing a new metric) for a CLIENT-LOCAL UI convenience that never touches the wire or any authoritative state — closing the panel is not gated by, nor gates, anything server-visible. | The panel can close up to (player radius + vendor radius) sooner than exact retail — typically well under a meter for a two-legged NPC — so a player standing exactly at the boundary of a large-radius vendor's `UseRadius` may see the panel close slightly earlier than retail would. No effect on any transaction, wire message, or authoritative state (Slice 6's buy/sell owns those). Retiring this row requires a Runtime-owned per-entity collision radius/height source, which does not exist today. | `CPlayerSystem::RegisterObjectRangeHandler` pc:203677/0x004C4C34; `gmVendorUI::OnObjectRangeExit` pc:199486/0x004C02F0; ACE `Vendor.CheckClose`/`GetCylinderDistance` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367`, `WorldObject_Use.cs:50,57`); `docs/research/2026-08-08-slice5-vendor-browse-research.md` §A.3/§B.1/§B.2 |
| AP-141 | **Filed 2026-08-04, C4 route 5 (projectile authoritative placement); NARROWED 2026-08-04 at the round-2 delta review (B1/B2) — the far-branch clause was factually wrong for the adopted-body case and is corrected below.** Three related projectile-only shapes, all pinned by design (D-P4) rather than ported: (a) the near-`Interpolate` disposition is a NO-OP for a live missile, where retail would lazily build interpolation machinery (`InterpolateTo` @0x005163AF) for it; (b) the post-operation `ConstrainTo` @0x00454272 (`MakePositionManager` @0x00510523 then `PositionManager::ConstrainTo`) is never ARMED for a projectile — retail's single arming site has no kind test, so retail WOULD build a `PositionManager` on demand and arm a missile's leash on any nonzero `MoveOrTeleport` return; acdream never arms it on any disposition, including the adopted-body case (whose PRE-EXISTING leash the teleport/far branches now un-arm or clear queue state for, but never RE-anchor, per retail's post-operation `ConstrainTo`); (c) a null-classified or `Rejected*` accepted Position for a missile is swallowed (write nothing) rather than caught up through any remote-shaped policy. | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`ApplyAcceptedProjectilePosition`) | acdream deliberately does not construct an `EntityPhysicsHost`/`PositionManager`/`InterpolationManager` chain for a ballistic body — the route-5b split the C4 route 5 contract rejected. The context that makes this safe rather than merely convenient: ACE never sends `UpdatePosition` for a missile (`references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:333-334`, `SendUpdatePosition()` commented out inside the `PhysicsState.Missile` branch at `:265`) — every half of this row is deterministic-test-gated only, never exercised against a real server. **The far branch's `StopInterpolating` skip is retail-faithful ONLY for a BARE missile** (no `RemoteMotion` — retail's own `position_manager != 0` guard @0x005163C9 skips it for a never-interpolated object, so acdream's skip is faithful by consequence there). For the ADOPTED-BODY case (`TryBind`'s shared-body branch: an ordinary remote whose Missile bit was set by a later State packet, still carrying its `RemoteMotion`), retail's guard IS satisfied and retail WOULD clear the queue — acdream now ports this (`route.StopInterpolating && record.RemoteMotion is RemoteMotion adopted → adopted.Interp.Clear()`), matching the teleport branch's equivalent `StopInterpolating` action inside `teleport_hook`. What remains divergent for the adopted case is the post-operation `ConstrainTo` re-anchor @0x00454272 — retail re-anchors an existing leash at the just-updated position on every nonzero return; acdream never arms/re-anchors it on any projectile disposition (clause (b)). | A future change that DOES give projectiles a `PositionManager` (or a headless/no-window remote-motion consumer that expects one) must re-decide this row rather than silently building the machinery ad hoc; until then, a live missile never shows an ARMED constraint leash and never catches up via the near/UnroutedCatchUp policy — both unreachable in play. An adopted-body missile's INHERITED leash (armed before it became a missile) is un-armed by the teleport hook, has its queue cleared by both teleport and far, but is never re-anchored at the new position by either — its brake accumulator (`ConstraintPosOffset`) is not reset to zero at each accepted Position the way retail's @0x00454272 re-anchor does. **Correction, round 3 (2026-08-04): the round-2 wording here — that a stale leash "would drag the body toward a stale anchor" — was wrong and is retracted.** `ConstraintManager.ConstraintPos` is write-only in both retail and the port (never read by `AdjustOffset`), and `ConstraintManager::adjust_offset` @0x00556180 only tapers or zeroes an already-composed per-tick offset while `InContact` — a leash brakes motion the interp/sticky chain already produced; it has no mechanism to move anything toward the anchor. The real residual is confined to one tick of un-reset brake accumulator, contact-gated, and it cannot move an airborne far-snapped missile at all (the clamp branch does not run while airborne). | `CPhysicsObj::MoveOrTeleport` 0x00516330 (`InterpolateTo` @0x005163AF, `IsMovingTo` @0x0050EB10 returning 0 without a `MovementManager`; far branch `StopInterpolating` @0x005163C9-@0x005163CB); `SmartBox::HandleReceivedPosition` 0x00453FD0 (`ConstrainTo` arming site @0x00454272); `CPhysicsObj::ConstrainTo` 0x00510520 (`MakePositionManager` @0x00510523); `ConstraintManager::adjust_offset` 0x00556180 (brake-only taper, write-only anchor); `WorldObject_Tick.cs:333-334`/`:265` (ACE never-sends evidence) |
| AP-142 | **Filed 2026-08-04 (C4 route 7, pickup/parent/delete). AMENDED 2026-08-04 at the dual-Opus retail-conformance/architecture review round (R1/A8 MAJOR+LOW; R10 MINOR) — clause (d) added, clause (b) corrected. AMENDED AGAIN 2026-08-04 at the round-3 dual review (N1/N2/N4, B3) — clause (d)'s reasoning corrected and its risk-column scope widened; clause (e) RETIRED — the depth cap it described is deleted outright, replaced by an iterative worklist with no depth concept at all. AMENDED AGAIN 2026-08-05 at the #319 fix — clause (f) added. AMENDED AGAIN 2026-08-05 at the #319 dual-review round (retail PASS, architecture FAIL/6 MAJORs) — clause (f) rewritten: the tripwire moved above the canonical commit and no longer throws (A1), and the deferred late-bind queue A1's fix text originally described was deleted per A6 (both reviews proved it production-unreachable for both producers).** acdream collapses retail's `CPhysicsObj` pair — a `cell` pointer plus a separately-written `objcell_id` — into ONE canonical `RuntimeEntityRecord.FullCellId`, which is also the residency/liveness predicate acdream reads at 45+ sites. Four consequences, all intentional: (a) the removal path propagates ZERO to a subtree's children (withdrawal, delete, `EndGeneration`), where retail's `leave_cell` recursion nulls only each child's `cell` pointer and leaves a STALE non-zero `objcell_id` (`change_cell`'s removal tail @0x005133C1 never touches a child's id) — reproducing that stale-id residue would leave a child "resident" per every acdream predicate while retail's own gating field (`cell == nullptr`) says it is not; (b) retail's same-cell depth-1 per-tick `objcell_id` refresh (`SetPositionInternal` @0x0051539c-@0x005153d8, gated on the parent NOT crossing a cell) is subsumed by the value-idempotent propagation chokepoint (`RuntimeEntityDirectory.SetFullCell`'s "skip a child whose `FullCellId` already equals the target" guard) rather than ported as a separate tick loop — a same-value restamp is unobservable with one field playing both retail roles. **Correction (R10): this is a clean equivalence only on the REMOVAL side.** The skip ALSO prunes the child's whole subtree on a same-value WRITE, which retail's `enter_cell` does not do — it recurses over children unconditionally (@0x00510f03); only `leave_cell` prunes (@0x00510f5b, on `cell != 0`). Currently unreachable-by-construction (after D4 nothing writes a grandchild's cell independently of its own committed parent), but it is an asymmetry, not a proven equivalence; (c) the sustaining propagation itself: retail re-cells children when the parent crosses a cell, recursively, on EVERY `SetPositionInternal`/`change_cell` (@0x00515372/@0x00513390), not only at attach — acdream ports this as a single hook every canonical cell-write funnels through, so an attach-only write (the pre-existing shape) is deliberately NOT what shipped. **(d) retail's `enter_cell` gates its ENTIRE body — the write AND the recursion into children — on `this->part_array != 0` (@0x00510ed8); a child with a null part array receives nothing and its whole subtree is skipped. acdream's propagation has NO analogue and writes unconditionally. CORRECTED reasoning (round-3 review, N1/N2): the original draft of this clause argued acdream's `HasPartArray` means something semantically different from retail's `part_array` (a "renderer built a mesh" flag vs. "this CPhysicsObj has any part array"). That framing is WRONG — retail's `part_array` has exactly ONE assignment site, `CPhysicsObj::makeAnimObject` @0x0050e930 → `CPartArray::CreateSetup`, assigned @0x0050e94d, so retail's flag is ALSO a mesh-construction product; the two are near-synonyms, not different concepts. The REAL reason acdream cannot gate the canonical D1/D2 write on `HasPartArray` is LAYERING, not semantics: Slice J made the Runtime canonical layer presentation-independent by design (`docs/research/2026-07-25-slice-j1-runtime-contract-closeout.md` and the Slice J campaign generally), and `HasPartArray` is populated exclusively by App/graphical code (`EquippedChildRenderController.cs:609`, `DatLiveEntityProjectionMaterializer.cs:203`) — the canonical layer structurally cannot depend on a flag only the presentation layer ever writes, headless or not. CORRECTED scope (round-3 review): this is NOT headless-only. `PrepareAndTryRealize` calls `CommitAcceptedParentCellless` (hence D1's re-cell) BEFORE `TryRealize` sets `HasPartArray = true` at `:609` — so at the exact moment D1 runs, `child.HasPartArray` is FALSE in the GRAPHICAL host too, and gating on it would break attach there as well, not just headless. Retail has no equivalent window at all: `part_array` is assigned once at construction and `enter_cell`'s guard reads that same, already-settled field.** The guard is deliberately NOT reproduced at the canonical layer. **(e) RETIRED 2026-08-04 (round-3 review, N4/B3 — both reviews independently found the same defect).** Previously: recursion depth capped at 64 levels as hostile/buggy-server hardening. The cap's actual failure mode was worse than what it guarded against: a subtree beyond the cap was left at its PRIOR — on the withdraw path, STALE NONZERO — cell PERMANENTLY, logged only under a probe flag nobody runs by default. On the withdraw path that is the #184 shape verbatim: an entity every acdream residency predicate calls resident that retail (and clause (a) above) says is not. Shipping that inside the slice whose headline is fixing exactly this class was unacceptable. Retired by deleting the cap outright and replacing the recursion with an iterative worklist (`RuntimeEntityDirectory._propagationWorklist`), which has no stack-frame-bounded depth at all — the only limit is the number of committed relations actually in the system, matching retail's own genuinely unbounded recursion with no acdream-only cap and therefore no register row for one. **(f) Filed 2026-08-05 (#319 fix).** A CreateObject-carried parent relation (the raw spawn's `Physics.Parent` field, and the same-generation `CreateParentUpdate` envelope) names the parent's GUID and location only — neither wire shape carries a parent instance sequence, matching retail's own GUID-only attach (`PhysicsDesc::get_parent_id` @0x00558a18 → `CObjectMaint::GetObjectA` @0x00558a2d → `CPhysicsObj::set_parent` @0x00558a3e; the reverse `CObjectMaint::SetChildren` @0x00509370 hash-walks by guid with a `GetNullObject` placeholder @0x005093e6 — no instance-sequence field or comparison exists anywhere in either direction). acdream's committed-relation table is nonetheless keyed by (guid, incarnation) (clause (c)'s D1/D2 requirement), so a CreateObject-carried relation must adopt SOME incarnation to file under; it now LATE-BINDS to the parent's LIVE incarnation at accept time (`EquippedChildRenderController.AcceptLateBoundCreateObjectRelation`, both the raw-CreateObject and same-generation `CreateParentUpdate` producers) rather than the previously-hardcoded 0, which silently mis-keyed every player-parented CreateObject relation (a player's `ObjectInstance` is `Character.TotalLogins`, never 0) and defeated D1/D2 for the local player's own login equipment and every remote player's observed equipment (#319). A commit-time tripwire (`ParentAttachmentState.CanCommitIncarnation`, checked BEFORE either half of the commit mutates state — architecture review A1, 2026-08-05, moved it there after the original throw-after-canonical-commit shape was shown to tear the transaction it was built to protect) refuses (logs, returns false, never throws) rather than silently filing a relation under a mismatched incarnation whenever the parent is currently addressable. **A1 also settled A6's design question**: an initial revision queued a relation whose parent was not yet addressable through a deferred/late-bind retry mechanism; both reviews independently proved that queue was structurally unreachable in production for BOTH producers (`RuntimeEntityObjectLifetime.RegisterEntityCore`'s `EnqueueDeferredCreate` gate defers the ENTIRE CreateObject, for both wire shapes, before either producer ever runs) while carrying three latent defects of its own (a missing child POSITION_TS gate, a placeholder-incarnation collision with the generation filters, unbounded accumulation) — it was deleted rather than fixed in place; the unaddressable-parent case now logs and refuses outright, matching the invariant the layer above already enforces. | `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs` (`SetFullCell`, `PropagateFullCellToChildren`, `RefreshSnapshot`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`CommitAcceptedParentCellless`'s D1 half, `WithdrawCommittedChildrenToCellless`); `src/AcDream.Runtime/Entities/ParentAttachmentState.cs` (`TryGetCommittedParent`, `CanCommitIncarnation`, `CommitProjection`); `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs` (`HasPartArray`); `src/AcDream.App/Rendering/EquippedChildRenderController.cs` (`AcceptLateBoundCreateObjectRelation`, `OnSpawn`, `OnCreateParentAccepted`, `PrepareAndTryRealize`); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`ResolveAndCommitChildAttachment`) | Reproducing retail's pointer/id split would require a second field acdream's 45+ liveness call sites would then have to be individually audited for which half they mean — the single-field model is a stated, load-bearing simplification, not an oversight; see `docs/research/2026-08-04-retail-parent-cell-propagation.md` and `docs/research/2026-08-04-c4-route-7-contract.md` D2/D3/D9. Clause (f) is retail-faithful for the identical reason clauses (a)-(d) are: retail's attach has no incarnation gate on this path at all, so adopting the current holder of the guid IS the retail behavior, not an approximation of it. | A future consumer that expects retail's exact stale-`objcell_id`-under-a-null-`cell` shape (none identified) would see a fully cell-less child instead. (d)'s risk: acdream celling a child retail would leave nowhere — none identified in play against a well-behaved ACE, since a server-authored equip always names a real, DAT-resolvable Setup, and the graphical host's own brief pre-`TryRealize` window is bridged by D1 running inside the same synchronous transaction as the rest of the attach commit, not by `HasPartArray` being true. (f)'s risk: none identified against a well-behaved ACE — a CreateObject's parent guid always names the entity that currently holds it by construction. | `CPhysicsObj::change_cell` 0x00513390 (@0x005133C1 removal tail); `CPhysicsObj::enter_cell` 0x00510ed0 (@0x00510ed8 the `part_array` guard); `CPhysicsObj::leave_cell` 0x00510f50; `CPhysicsObj::SetPositionInternal` 0x00515330 (@0x0051536d branch, @0x0051539c-@0x005153d8 same-cell loop, @0x00515372 cell-change branch); `CPhysicsObj::makeAnimObject` 0x0050e930 (`CPartArray::CreateSetup` assignment @0x0050e94d); `PhysicsDesc::get_parent_id` 0x00558a18; `CObjectMaint::GetObjectA` 0x00558a2d; `CPhysicsObj::set_parent` 0x00558a3e; `CObjectMaint::SetChildren` 0x00509370 (`GetNullObject` placeholder @0x005093e6) |
| AP-143 | **Filed 2026-08-04 (C4 route 7 D5, headless parent-realize drive). AMENDED 2026-08-04 at the retail-conformance review round (R7 MINOR) — this row originally described only ONE of the three checks the drive skips. Line citations corrected at the round-3 review (N3).** The graphical `EquippedChildRenderController.ValidateParentProjection` performs three retail-anchored checks before accepting a parent-attach request: (1) self-parenting rejection (`relation.ParentGuid == relation.ChildGuid`, `:915-916`); (2) the parent must have a constructed part array (`parent.HasPartArray`, `:920` — the closest acdream analogue to retail's `part_array != 0` guard, AP-142 clause d); (3) `Setup.HoldingLocations` validates the specific holding location (`CSetup::GetHoldingLocation` @0x0050F896, via `PartArray::add_child`). `AcDream.Headless`/`AcDream.Runtime`'s direct-host parent-realize drive (`RuntimeLiveEntitySessionController.ResolveAndCommitChildAttachment`) performs NONE of the three — it commits on the POSITION_TS gate acceptance and relation resolution alone. (1) is inert by construction: D1's re-cell gate reads `parent.FullCellId == 0` (the child was just zeroed by the cell-less edge before D1 runs), and D2's skip-on-equal terminates the resulting one-node cycle — a self-parent headless commits the relation but never observably re-cells through it. (2) has no headless analogue at all (see AP-142 clause d — `HasPartArray` is populated only by the graphical mesh pipeline, never headless, for ANY entity). (3) has no prepared-content surface (repo-wide grep confirms nothing under `src/AcDream.Content`/`AcDream.Bake` carries `Setup.HoldingLocations`). | `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`ResolveAndCommitChildAttachment`) | Precedent: the content-less host already accepts reduced fidelity elsewhere (`RuntimeLiveEntitySessionController:108-117`'s documented content-less registration). A server-sent self-parent, part-array-less parent, or invalid holding location is unreachable against a well-behaved ACE (ACE only emits `ParentEvent` for a location its own `Player_Inventory`/wield validation already accepted), so this is a defense-in-depth gap, not a live-play one. | A malicious or buggy server could attach a child headless where retail and the graphical host would both reject it — inert against ACE today for all three. Retiring (3) means extending the prepared-content bake format with `Setup.HoldingLocations`, deliberately NOT done in this slice (route 7 contract §4 D5); (2) has no retiring action available until acdream's canonical layer gains its own construction-time part-array concept (a larger architectural question, out of scope here). | `PartArray::add_child` (`CSetup::GetHoldingLocation` 0x0050F896); `CPhysicsObj::enter_cell` 0x00510ed8 (the `part_array` guard); `EquippedChildRenderController.ValidateParentProjection` (graphical port, all three checks) |
diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index 4d084b7f..78354311 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -332,6 +332,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.PlayerIdentity.ServerGuid),
groundObjectId: () =>
d.Inventory.ExternalContainers.CurrentContainerId,
+ // Slice 5.3: finally wires the dormant vendor-id seam
+ // (ItemInteractionPolicy.ActiveVendorId's "using an item inside
+ // the currently-open vendor's shop is swallowed as a no-op"
+ // branch, research doc §C.1). A live delegate rather than a
+ // captured value, so it reads 0 automatically once VendorState
+ // closes — no separate "clear on close" wiring needed.
+ activeVendorId: () => d.Inventory.Vendor.VendorId,
sendSplitToWorld: (item, amount) =>
session.CurrentSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () =>
diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
index 5e5b48f7..530fa54d 100644
--- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
+++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
@@ -286,7 +286,8 @@ internal sealed class LiveSessionRuntimeFactory
retailUi.HandleAppraisal(appraisal);
else
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
- });
+ },
+ Vendor: _domain.Inventory.Vendor);
private LiveCharacterSessionBindings CreateCharacterBindings(
SkillTable? skillTable)
diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index 068d3763..281c5762 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -83,6 +83,10 @@ public static class GameEventWiring
Action? onCharacterOptions = null,
Func? clientTime = null,
ExternalContainerState? externalContainers = null,
+ // Slice 5.3: the vendor browse session owner. Matches the existing
+ // itemMana/friends/squelch/externalContainers pattern — optional so
+ // every existing caller compiles unchanged.
+ VendorState? vendor = null,
Func? accepting = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
@@ -375,6 +379,51 @@ public static class GameEventWiring
items.UpdateHouseRestrictions(p.Value.SenderId, p.Value.Restrictions);
});
+ // Slice 5.3: ApproachVendor (0x0062) — the sole wire message that
+ // opens a vendor's shop; it rides the ordinary Use action, there is
+ // no separate "open vendor" opcode (research doc
+ // docs/research/2026-08-08-slice5-vendor-browse-research.md §A.1-A.2).
+ // Every event is a COMPLETE REPLACE (§A.3) — VendorState.Apply is a
+ // single-phase authoritative-replace call, matching that contract.
+ // A malformed payload is dropped silently: every sibling handler in
+ // this section (WieldObject, InventoryPutObjInContainer,
+ // HouseUpdateRestrictions above, ViewContents/CloseGroundContainer
+ // below) uses the same `if (p is null) return;` shape with no
+ // logging — there is no established parse-failure logging
+ // convention in this file to deviate from.
+ registrar.Register(GameEventType.ApproachVendor, e =>
+ {
+ var p = VendorApproach.TryParse(e.Payload.Span);
+ if (p is null) return;
+
+ var profile = new VendorShopProfile(
+ p.Value.Profile.MerchandiseItemTypes,
+ p.Value.Profile.MerchandiseMinValue,
+ p.Value.Profile.MerchandiseMaxValue,
+ p.Value.Profile.DealMagicalItems,
+ p.Value.Profile.BuyPrice,
+ p.Value.Profile.SellPrice,
+ p.Value.Profile.AlternateCurrencyWcid,
+ p.Value.Profile.AlternateCurrencyAmount,
+ p.Value.Profile.AlternateCurrencyPluralName);
+
+ var shopItems = new VendorShopItem[p.Value.Items.Count];
+ for (int i = 0; i < shopItems.Length; i++)
+ {
+ VendorApproach.ItemProfile item = p.Value.Items[i];
+ shopItems[i] = new VendorShopItem(
+ item.ItemGuid,
+ item.StackSize,
+ item.Desc.WeenieClassId,
+ item.Desc.Name,
+ item.Desc.ItemType,
+ item.Desc.IconId,
+ item.Desc.Value);
+ }
+
+ vendor?.Apply(p.Value.VendorGuid, profile, shopItems);
+ });
+
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
// opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without
// inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index 91fc2743..23623cc0 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -711,7 +711,8 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime.InventoryOwner.ExternalContainers,
appraisal =>
Runtime.ActionOwner.Transactions
- .AcceptAppraisalResponse(appraisal.Guid)),
+ .AcceptAppraisalResponse(appraisal.Guid),
+ Vendor: Runtime.InventoryOwner.Vendor),
new LiveCharacterSessionBindings(
Runtime.ActionOwner.Combat,
Runtime.CharacterOwner,
diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs
index 1fba5d08..c5f742e3 100644
--- a/src/AcDream.Runtime/GameRuntime.cs
+++ b/src/AcDream.Runtime/GameRuntime.cs
@@ -409,7 +409,16 @@ public sealed class GameRuntime
return new RuntimeLocalPlayerFrameController(
host,
input,
- () => _events.EmitMovement(MovementOwner.Snapshot));
+ () =>
+ {
+ _events.EmitMovement(MovementOwner.Snapshot);
+ // Slice 5.3: the local-player movement publish already fires
+ // once per advanced frame for both graphical and no-window
+ // hosts (RuntimeLocalPlayerFrameController.RunPostNetworkCommandPhase),
+ // so the client-local vendor distance watcher piggybacks on
+ // it instead of adding a second polling loop.
+ RuntimeVendorRangeQuery.EnforceRange(this);
+ });
}
public void ResetGeneration(
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs
index 7f8c3527..ebba7eb4 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs
@@ -15,7 +15,9 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
int ShortcutCount,
int ShortcutSubscriberCount,
long ShortcutDispatchFailureCount,
- long TransactionDispatchFailureCount)
+ long TransactionDispatchFailureCount,
+ // Slice 5.3: the sole open vendor shop id, 0 when no session is open.
+ uint VendorId)
{
public bool IsConverged =>
IsDisposed
@@ -27,7 +29,8 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
&& CurrentContainerId == 0u
&& ItemManaCount == 0
&& ShortcutCount == 0
- && ShortcutSubscriberCount == 0;
+ && ShortcutSubscriberCount == 0
+ && VendorId == 0u;
}
///
@@ -48,6 +51,13 @@ public sealed class RuntimeInventoryState : IDisposable
ItemMana = new ItemManaState();
Shortcuts = new ShortcutStore();
Transactions = new InventoryTransactionState(_entityObjects.Objects);
+ // Slice 5.3: VendorState joins RuntimeInventoryState exactly the way
+ // ExternalContainerState does (research doc
+ // docs/research/2026-08-08-slice5-vendor-browse-research.md §C.2) —
+ // same "authoritative server-driven full-replace view... with a
+ // Changed event for presentation observers" shape, generation-gated
+ // and torn down alongside the rest of this owner's children.
+ Vendor = new VendorState();
View = new InventoryStateView(this);
}
@@ -56,6 +66,7 @@ public sealed class RuntimeInventoryState : IDisposable
public ItemManaState ItemMana { get; }
public ShortcutStore Shortcuts { get; }
public InventoryTransactionState Transactions { get; }
+ public VendorState Vendor { get; }
public IRuntimeInventoryStateView View { get; }
public bool IsDisposed => _disposed;
@@ -71,11 +82,13 @@ public sealed class RuntimeInventoryState : IDisposable
Shortcuts.Count,
Shortcuts.SubscriberCount,
Shortcuts.DispatchFailureCount,
- Transactions.DispatchFailureCount);
+ Transactions.DispatchFailureCount,
+ Vendor.VendorId);
public void ResetExternalContainer() => ExternalContainers.Reset();
public void ResetTransactions() => Transactions.ResetSession();
public void ResetItemMana() => ItemMana.Clear();
+ public void ResetVendor() => Vendor.Reset();
public void ResetPlayerSnapshots()
{
@@ -142,6 +155,7 @@ public sealed class RuntimeInventoryState : IDisposable
try
{
Try(() => ExternalContainers.Reset(), ref failures);
+ Try(() => Vendor.Reset(), ref failures);
Try(ItemMana.Clear, ref failures);
Try(Shortcuts.Dispose, ref failures);
Try(Transactions.Dispose, ref failures);
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs b/src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs
new file mode 100644
index 00000000..bb1e6680
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs
@@ -0,0 +1,110 @@
+using System.Numerics;
+using AcDream.Core.Items;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Runtime.Entities;
+
+namespace AcDream.Runtime.Gameplay;
+
+///
+/// Client-local vendor-panel distance watcher (Slice 5.3, research doc
+/// docs/research/2026-08-08-slice5-vendor-browse-research.md §A.3/§B.1/
+/// §B.2). Retail's gmVendorUI::OpenVendor (pc:203650, 0x004C4BA0)
+/// registers a range handler via CPlayerSystem::RegisterObjectRangeHandler
+/// (pc:203677, 0x004C4C34) keyed to the vendor's OWN
+/// PublicWeenieDesc._useRadius; gmVendorUI::OnObjectRangeExit
+/// (pc:199486, 0x004C02F0) then calls gmVendorUI::CloseVendor
+/// (pc:202080, 0x004C3020) — a pure client-side teardown, no wire message.
+/// ACE's server-side belt-and-suspenders equivalent, Vendor.CheckClose
+/// (references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367),
+/// polls every 1.5 s and closes when GetCylinderDistance(lastPlayer) >
+/// UseRadius, falling back to wo.UseRadius ?? 0.6f when the vendor
+/// carries no explicit radius (WorldObject_Use.cs:50,57).
+///
+public static class RuntimeVendorRangeQuery
+{
+ /// ACE WorldObject_Use.cs:50: wo.UseRadius ?? 0.6f.
+ private const float DefaultUseRadius = 0.6f;
+
+ ///
+ /// Close the open vendor session (if any) once the local player has
+ /// moved beyond the vendor's own UseRadius. No-op when no vendor is
+ /// open, or when either side's live position cannot be resolved this
+ /// tick (matches the existing App-layer convention at
+ /// WorldSelectionQuery.IsWithinExternalContainerUseRange: "the
+ /// server remains authoritative while render projection is absent" —
+ /// never force-close on missing data).
+ ///
+ ///
+ /// Distance metric divergence (register AP-160): retail/ACE close
+ /// on CYLINDER-GAP distance — both objects' own collision radius and
+ /// height subtracted from the center distance
+ /// (Position::cylinder_distance/ACE's GetCylinderDistance).
+ /// Runtime does not resolve a live per-NPC collision radius/height
+ /// outside the App-layer's Setup-cylinder resolver
+ /// (WorldSelectionQuery, App-only — out of reach per the
+ /// Core-structure rules), so this uses plain 3D center-to-center
+ /// distance via 's
+ /// useRadii: false branch instead. Effect: the panel can close up
+ /// to (player radius + vendor radius) sooner than exact retail —
+ /// typically well under a meter for a two-legged NPC.
+ ///
+ ///
+ public static void EnforceRange(GameRuntime runtime)
+ {
+ ArgumentNullException.ThrowIfNull(runtime);
+
+ VendorState vendor = runtime.InventoryOwner.Vendor;
+ uint vendorId = vendor.VendorId;
+ if (vendorId == 0u)
+ return;
+
+ uint playerGuid = runtime.PlayerIdentity.ServerGuid;
+ if (playerGuid == 0u
+ || !runtime.EntityObjects.Entities.TryGetActive(
+ playerGuid,
+ out RuntimeEntityRecord playerRecord)
+ || playerRecord.Snapshot.Position is not { } playerPosition)
+ {
+ return;
+ }
+
+ if (!runtime.EntityObjects.Entities.TryGetActive(
+ vendorId,
+ out RuntimeEntityRecord vendorRecord)
+ || vendorRecord.Snapshot.Position is not { } vendorPosition)
+ {
+ return;
+ }
+
+ float useRadius = vendorRecord.Snapshot.UseRadius ?? DefaultUseRadius;
+ bool inRange = ObjectRangeMath.ObjectsInRange(
+ AbsolutePosition(playerPosition),
+ 0f,
+ 0f,
+ AbsolutePosition(vendorPosition),
+ 0f,
+ 0f,
+ useRadius,
+ useRadii: false,
+ ignoreZDelta: false);
+
+ if (!inRange)
+ vendor.Close();
+ }
+
+ ///
+ /// Same reconstruction uses:
+ /// wire local XYZ plus the landblock-prefix world offset (each
+ /// landblock is 192 m).
+ ///
+ private static Vector3 AbsolutePosition(CreateObject.ServerPosition position)
+ {
+ int landblockX = (int)((position.LandblockId >> 24) & 0xFFu);
+ int landblockY = (int)((position.LandblockId >> 16) & 0xFFu);
+ return new Vector3(
+ position.PositionX + landblockX * 192f,
+ position.PositionY + landblockY * 192f,
+ position.PositionZ);
+ }
+}
diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs
index 419ad8e9..6bbdff8a 100644
--- a/src/AcDream.Runtime/RuntimeGenerationReset.cs
+++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs
@@ -246,7 +246,18 @@ public sealed class RuntimeGenerationReset
Advance(state, _communication.ResetCommandTargets);
break;
case RuntimeGenerationResetStage.ExternalContainer:
- Advance(state, _inventory.ResetExternalContainer);
+ // Slice 5.3: the vendor browse session shares the
+ // external-container stage rather than claiming a new
+ // enum ordinal — both are client-local "open server
+ // object" sessions torn down uniformly at session
+ // reset/portal-out/logout (research doc §C.2's
+ // "generation/lifecycle contract every other J4/J5 child
+ // follows").
+ Advance(state, () =>
+ {
+ _inventory.ResetExternalContainer();
+ _inventory.ResetVendor();
+ });
break;
case RuntimeGenerationResetStage.Actions:
Advance(state, _actions.ResetSession);
diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
index 9098d5e3..cbdd7306 100644
--- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
@@ -37,7 +37,10 @@ public sealed record LiveInventorySessionBindings(
Action? OnUseDone,
ItemManaState? ItemMana,
ExternalContainerState? ExternalContainers,
- Action? OnAppraisal = null);
+ Action? OnAppraisal = null,
+ // Slice 5.3: the vendor browse session owner. Trailing/optional so every
+ // existing positional caller (Headless) compiles unchanged.
+ VendorState? Vendor = null);
public sealed record LiveCharacterSessionBindings(
CombatState Combat,
@@ -186,6 +189,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onCharacterOptions: character.Character.Options.Replace,
clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers,
+ vendor: inventory.Vendor,
accepting: IsAccepting));
ConstructionCheckpoint();
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs
new file mode 100644
index 00000000..6a41ca45
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs
@@ -0,0 +1,413 @@
+using System.Buffers.Binary;
+using System.Text;
+using AcDream.Core.Chat;
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Spells;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.Runtime.Tests.Gameplay;
+
+///
+/// Slice 5.3 — Runtime ownership of the vendor browse session:
+/// is populated by the inbound
+/// ApproachVendor (0x0062) route (GameEventWiring.WireAll's
+/// vendor parameter) and torn down by the shared generation-reset
+/// mechanism (session reset / portal-out / logout).
+///
+public sealed class RuntimeVendorLifecycleTests
+{
+ private const uint Player = 0x50000001u;
+
+ [Fact]
+ public void ApproachVendorEvent_PopulatesVendorStateWithProfileAndItems()
+ {
+ using GameRuntime runtime = Create();
+ VendorState vendor = runtime.InventoryOwner.Vendor;
+ using IDisposable wiring = Wire(vendor);
+
+ Dispatch(BuildApproachVendorPayload(
+ vendorGuid: 0x40001000u,
+ categories: 0x42u,
+ minValue: 5u,
+ maxValue: 500u,
+ dealsMagic: true,
+ buyPrice: 0.8f,
+ sellPrice: 1.3f,
+ currencyWcid: 0u,
+ currencyAmount: 0u,
+ currencyName: "",
+ items:
+ [
+ new VendorItemFixture(
+ ItemGuid: 0x50002000u,
+ StackSize: 1,
+ Name: "Iron Sword",
+ WeenieClassId: 42u,
+ RawIconId: 0x1234u,
+ ItemType: (uint)ItemType.Weapon,
+ Value: 250),
+ ]));
+
+ Assert.Equal(0x40001000u, vendor.VendorId);
+ // Profile fields — including the price-relevant rates (research doc
+ // §B.1/§A.2: BuyPrice/SellPrice feed ShopSystem::BuyPrice/SellPrice).
+ Assert.Equal(0x42u, vendor.Profile.MerchandiseItemTypes);
+ Assert.Equal(5u, vendor.Profile.MerchandiseMinValue);
+ Assert.Equal(500u, vendor.Profile.MerchandiseMaxValue);
+ Assert.True(vendor.Profile.DealMagicalItems);
+ Assert.Equal(0.8f, vendor.Profile.BuyPrice);
+ Assert.Equal(1.3f, vendor.Profile.SellPrice);
+
+ VendorShopItem item = Assert.Single(vendor.Items);
+ Assert.Equal(0x50002000u, item.ItemGuid);
+ Assert.Equal(1, item.StackSize);
+ Assert.Equal("Iron Sword", item.Name);
+ Assert.Equal(42u, item.WeenieClassId);
+ Assert.Equal((uint)ItemType.Weapon, item.ItemType);
+ Assert.Equal(0x1234u | CreateObject.IconTypePrefix, item.IconId);
+ // Value is the price-relevant field: it feeds VendorPricing's
+ // BuyPrice/SellPrice formula alongside the profile rates above.
+ Assert.Equal(250, item.Value);
+ }
+
+ [Fact]
+ public void SecondApproachVendorEventFromDifferentVendor_ReplacesTheSession()
+ {
+ using GameRuntime runtime = Create();
+ VendorState vendor = runtime.InventoryOwner.Vendor;
+ using IDisposable wiring = Wire(vendor);
+ var kinds = new List();
+ vendor.Changed += t => kinds.Add(t.Kind);
+
+ Dispatch(BuildApproachVendorPayload(
+ vendorGuid: 0x40001000u,
+ categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
+ buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
+ currencyName: "",
+ items:
+ [
+ new VendorItemFixture(
+ 0x50002000u, 1, "First Vendor Item", 1u, 1u,
+ (uint)ItemType.Misc, 10),
+ ]));
+
+ Assert.Equal(0x40001000u, vendor.VendorId);
+ Assert.Single(vendor.Items);
+
+ Dispatch(BuildApproachVendorPayload(
+ vendorGuid: 0x40002000u,
+ categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
+ buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
+ currencyName: "",
+ items:
+ [
+ new VendorItemFixture(
+ 0x50003000u, 1, "Second Vendor Item A", 2u, 2u,
+ (uint)ItemType.Misc, 20),
+ new VendorItemFixture(
+ 0x50003001u, 1, "Second Vendor Item B", 3u, 3u,
+ (uint)ItemType.Misc, 30),
+ ]));
+
+ // Replaced, not merged: the second vendor's own guid and item list
+ // only, the first vendor's items are gone.
+ Assert.Equal(0x40002000u, vendor.VendorId);
+ Assert.Equal(2, vendor.Items.Count);
+ Assert.DoesNotContain(
+ vendor.Items,
+ i => i.ItemGuid == 0x50002000u);
+ Assert.Contains(
+ vendor.Items,
+ i => i.Name == "Second Vendor Item A");
+
+ // Both opens are a DIFFERENT vendor id, so both are "Opened" — never
+ // "Refreshed" (that kind is reserved for a same-guid re-approach,
+ // e.g. a Slice 6 post-buy/sell snapshot refresh).
+ Assert.Equal(
+ [VendorStateTransitionKind.Opened, VendorStateTransitionKind.Opened],
+ kinds);
+ }
+
+ [Fact]
+ public void MalformedApproachVendorEvent_IsDroppedWithoutStateChange()
+ {
+ using GameRuntime runtime = Create();
+ VendorState vendor = runtime.InventoryOwner.Vendor;
+ using IDisposable wiring = Wire(vendor);
+
+ Dispatch(BuildApproachVendorPayload(
+ vendorGuid: 0x40001000u,
+ categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
+ buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
+ currencyName: "",
+ items:
+ [
+ new VendorItemFixture(
+ 0x50002000u, 1, "Untouched Item", 1u, 1u,
+ (uint)ItemType.Misc, 10),
+ ]));
+ Assert.Equal(0x40001000u, vendor.VendorId);
+
+ // Two bytes cannot even hold the vendor guid's own u32 — TryParse's
+ // outer catch returns null and the handler's `if (p is null) return;`
+ // drops it silently (matches every sibling handler in
+ // GameEventWiring.cs — see the comment on the ApproachVendor
+ // registration).
+ Dispatch(new byte[] { 1, 2 });
+
+ Assert.Equal(0x40001000u, vendor.VendorId);
+ Assert.Single(vendor.Items);
+ Assert.Equal("Untouched Item", vendor.Items[0].Name);
+ }
+
+ [Fact]
+ public void ResetGeneration_ClearsTheOpenVendorSession()
+ {
+ // Covers session reset / portal-out / logout uniformly: research
+ // doc §C.2 confirms these all funnel through ONE Runtime mechanism
+ // (RuntimeGenerationReset's staged transaction), the same single
+ // call site ExternalContainerState's own reset already shares.
+ using GameRuntime runtime = Create();
+ runtime.PlayerIdentity.ServerGuid = Player;
+ Assert.True(runtime.InventoryOwner.Vendor.Apply(
+ 0x40001000u, default, Array.Empty()));
+ Assert.Equal(0x40001000u, runtime.InventoryOwner.Vendor.VendorId);
+
+ runtime.ResetGeneration(new RuntimeGenerationToken(1), new NoOpResetHost());
+
+ Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
+ Assert.Empty(runtime.InventoryOwner.Vendor.Items);
+ }
+
+ [Fact]
+ public void DisposingInventoryOwner_ClearsTheOpenVendorSession()
+ {
+ // The other teardown edge: final GameRuntime disposal (as opposed
+ // to an in-place generation reset) also converges Vendor to closed,
+ // per RuntimeInventoryState.Dispose's fixed-order child reset.
+ using GameRuntime runtime = Create();
+ Assert.True(runtime.InventoryOwner.Vendor.Apply(
+ 0x40001000u, default, Array.Empty()));
+
+ runtime.InventoryOwner.Dispose();
+
+ Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
+ }
+
+ [Fact]
+ public void VendorId_IsTheLiveActiveVendorIdSeamSource()
+ {
+ // The App-layer ItemInteractionController wires
+ // `activeVendorId: () => runtime.InventoryOwner.Vendor.VendorId` as
+ // a LIVE delegate at its one construction site
+ // (InteractionRetainedUiComposition.CreateItemInteraction) — so this
+ // property IS that seam's data source. Verified here at the Runtime
+ // layer since ItemInteractionController itself lives in
+ // AcDream.App, which AcDream.Runtime.Tests cannot reference.
+ using GameRuntime runtime = Create();
+ VendorState vendor = runtime.InventoryOwner.Vendor;
+ Assert.Equal(0u, vendor.VendorId);
+
+ Assert.True(vendor.Apply(
+ 0x40001000u, default, Array.Empty()));
+ Assert.Equal(0x40001000u, vendor.VendorId);
+
+ Assert.True(vendor.Close());
+ Assert.Equal(0u, vendor.VendorId);
+ }
+
+ // ---- fixtures / helpers ------------------------------------------
+
+ private readonly record struct VendorItemFixture(
+ uint ItemGuid,
+ int StackSize,
+ string Name,
+ uint WeenieClassId,
+ uint RawIconId,
+ uint ItemType,
+ int? Value);
+
+ private IDisposable Wire(VendorState vendor) => GameEventWiring.WireAll(
+ _dispatcher,
+ new ClientObjectTable(),
+ new CombatState(),
+ new Spellbook(),
+ new ChatLog(),
+ vendor: vendor);
+
+ // One dispatcher per test instance (xUnit constructs a fresh instance
+ // per test method, so this is not shared across tests).
+ private readonly GameEventDispatcher _dispatcher = new();
+
+ private void Dispatch(byte[] payload)
+ {
+ GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
+ WrapEnvelope(GameEventType.ApproachVendor, payload));
+ Assert.NotNull(envelope);
+ _dispatcher.Dispatch(envelope.Value);
+ }
+
+ private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
+ {
+ byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type);
+ Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
+ return body;
+ }
+
+ private static byte[] BuildApproachVendorPayload(
+ uint vendorGuid,
+ uint categories,
+ uint minValue,
+ uint maxValue,
+ bool dealsMagic,
+ float buyPrice,
+ float sellPrice,
+ uint currencyWcid,
+ uint currencyAmount,
+ string currencyName,
+ IReadOnlyList items)
+ {
+ var b = new List();
+ WireU32(b, vendorGuid);
+ WireU32(b, categories);
+ WireU32(b, minValue);
+ WireU32(b, maxValue);
+ WireU32(b, dealsMagic ? 1u : 0u);
+ WireF32(b, buyPrice);
+ WireF32(b, sellPrice);
+ WireU32(b, currencyWcid);
+ WireU32(b, currencyAmount);
+ WireStr16L(b, currencyName);
+ WireU32(b, (uint)items.Count);
+ foreach (VendorItemFixture item in items)
+ {
+ uint packed = ((uint)item.StackSize & 0xFFFFFFu) | 0xFF000000u;
+ WireU32(b, packed);
+ WireU32(b, item.ItemGuid);
+
+ // Fixed PWD prefix (PublicWeenieDescParser.Parse). Only weenieFlags
+ // bit 0x8 (Value) is set here — the single optional-tail field
+ // these fixtures need.
+ uint weenieFlags = item.Value.HasValue ? 0x00000008u : 0u;
+ WireU32(b, weenieFlags);
+ WireStr16L(b, item.Name);
+ WirePackedDword(b, item.WeenieClassId);
+ WirePackedDword(b, item.RawIconId);
+ WireU32(b, item.ItemType);
+ WireU32(b, 0u); // objectDescriptionFlags
+ WireAlign(b);
+ if (item.Value.HasValue)
+ WireU32(b, unchecked((uint)item.Value.Value));
+ // Per-item trailing align — VendorApproach.TryParse's own
+ // AlignTo4 call after the shared parser returns.
+ WireAlign(b);
+ }
+ return b.ToArray();
+ }
+
+ private static void WireU32(List b, uint v)
+ {
+ Span t = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(t, v);
+ b.AddRange(t.ToArray());
+ }
+
+ private static void WireF32(List b, float v) =>
+ WireU32(b, unchecked((uint)BitConverter.SingleToInt32Bits(v)));
+
+ private static void WireU16(List b, ushort v)
+ {
+ Span t = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16LittleEndian(t, v);
+ b.AddRange(t.ToArray());
+ }
+
+ /// ACE Extensions.cs:12-21 — u16 length, CP1252 bytes, pad to
+ /// a multiple of 4 including the 2 length bytes.
+ private static void WireStr16L(List b, string s)
+ {
+ byte[] bytes = Encoding.GetEncoding(1252).GetBytes(s);
+ WireU16(b, (ushort)bytes.Length);
+ b.AddRange(bytes);
+ int total = 2 + bytes.Length;
+ int pad = (4 - (total & 3)) & 3;
+ for (int i = 0; i < pad; i++) b.Add(0);
+ }
+
+ /// ACE Extensions.cs:23-34 — values <= 32767 as a plain u16;
+ /// larger values as the extended two-u16 form.
+ private static void WirePackedDword(List b, uint v)
+ {
+ if (v <= 32767)
+ {
+ WireU16(b, (ushort)v);
+ return;
+ }
+ uint packed = (v << 16) | ((v >> 16) | 0x8000);
+ WireU32(b, packed);
+ }
+
+ private static void WireAlign(List b)
+ {
+ int pad = (4 - (b.Count & 3)) & 3;
+ for (int i = 0; i < pad; i++) b.Add(0);
+ }
+
+ private static GameRuntime Create()
+ {
+ var operations = new Operations();
+ return new GameRuntime(new GameRuntimeDependencies(
+ operations,
+ operations,
+ operations,
+ operations));
+ }
+
+ private sealed class NoOpResetHost : IRuntimeGenerationResetHost
+ {
+ public void RetireEntityProjection(RuntimeEntityRecord entity) { }
+ public void DrainEntityProjectionBoundary() { }
+ public void CompleteEntityProjectionRetirement() { }
+ }
+
+ private sealed class Operations :
+ IRuntimeCombatAttackOperations,
+ IRuntimeCombatTargetOperations,
+ IRuntimeCombatModeOperations,
+ IRuntimeSpellCastOperations
+ {
+ public bool CanStartAttack() => false;
+ public void PrepareAttackRequest() { }
+ public bool SendAttack(AttackHeight height, float power) => false;
+ public void SendCancelAttack() { }
+ public bool IsDualWield => false;
+ public bool PlayerReadyForAttack => false;
+ public bool AutoRepeatAttack => false;
+ public bool AutoTarget => false;
+ public uint? SelectClosestTarget() => null;
+ public bool IsInWorld => false;
+ public IReadOnlyList GetOrderedEquipment() => [];
+ public void NotifyExplicitCombatModeRequest() { }
+ public void SendChangeCombatMode(CombatMode mode) { }
+ public uint LocalPlayerId => 0u;
+ public bool CanSend => false;
+ public bool HasRequiredComponents(uint spellId) => false;
+ public bool IsTargetCompatible(
+ uint targetId,
+ SpellMetadata spell,
+ bool showMessage) => false;
+ public void StopCompletely() { }
+ public void SendUntargeted(uint spellId) { }
+ public void SendTargeted(uint targetId, uint spellId) { }
+ public void DisplayMessage(string message) { }
+ public void IncrementBusy() { }
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorRangeQueryTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorRangeQueryTests.cs
new file mode 100644
index 00000000..32105e11
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorRangeQueryTests.cs
@@ -0,0 +1,206 @@
+using AcDream.Core.Combat;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Spells;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.Runtime.Tests.Gameplay;
+
+///
+/// Slice 5.3 — the client-local vendor-panel distance watcher
+/// (). Mirrors the GameRuntime test
+/// fixture RuntimeHostileTargetQueryTests already established for
+/// exactly this class of "position + distance" Runtime query.
+///
+public sealed class RuntimeVendorRangeQueryTests
+{
+ private const uint Player = 0x50000001u;
+ private const uint Vendor = 0x50000010u;
+ private const uint Landblock = 0x01010001u;
+
+ [Fact]
+ public void EnforceRange_PlayerWithinVendorUseRadius_LeavesSessionOpen()
+ {
+ using GameRuntime runtime = Create();
+ runtime.PlayerIdentity.ServerGuid = Player;
+ Add(runtime, Player, Landblock, 100f, 100f);
+ Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f); // 2 m away, 3 m radius
+ Open(runtime, Vendor);
+
+ RuntimeVendorRangeQuery.EnforceRange(runtime);
+
+ Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
+ }
+
+ [Fact]
+ public void EnforceRange_PlayerMovesBeyondVendorUseRadius_ClosesTheSession()
+ {
+ using GameRuntime runtime = Create();
+ runtime.PlayerIdentity.ServerGuid = Player;
+ RuntimeEntityRecord playerRecord =
+ Add(runtime, Player, Landblock, 100f, 100f);
+ Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f);
+ Open(runtime, Vendor);
+
+ // Walk 20 m away — well beyond the vendor's own authored 3 m radius.
+ SetPosition(playerRecord, Landblock, 122f, 100f);
+ RuntimeVendorRangeQuery.EnforceRange(runtime);
+
+ Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
+ }
+
+ [Fact]
+ public void EnforceRange_PlayerStaysWithinRadiusAfterSmallMove_LeavesSessionOpen()
+ {
+ using GameRuntime runtime = Create();
+ runtime.PlayerIdentity.ServerGuid = Player;
+ RuntimeEntityRecord playerRecord =
+ Add(runtime, Player, Landblock, 100f, 100f);
+ Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f);
+ Open(runtime, Vendor);
+
+ // Shuffle 1 m closer — still well inside the 3 m radius.
+ SetPosition(playerRecord, Landblock, 101f, 100f);
+ RuntimeVendorRangeQuery.EnforceRange(runtime);
+
+ Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
+ }
+
+ [Fact]
+ public void EnforceRange_NoVendorOpen_IsANoOpAndDoesNotThrow()
+ {
+ using GameRuntime runtime = Create();
+ runtime.PlayerIdentity.ServerGuid = Player;
+ Add(runtime, Player, Landblock, 100f, 100f);
+
+ RuntimeVendorRangeQuery.EnforceRange(runtime);
+
+ Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
+ }
+
+ [Fact]
+ public void EnforceRange_VendorUseRadiusAbsent_FallsBackToTheAceDefault()
+ {
+ // ACE WorldObject_Use.cs:50 — `wo.UseRadius ?? 0.6f`.
+ using GameRuntime runtime = Create();
+ runtime.PlayerIdentity.ServerGuid = Player;
+ RuntimeEntityRecord playerRecord =
+ Add(runtime, Player, Landblock, 100f, 100f);
+ Add(runtime, Vendor, Landblock, 100.5f, 100f, useRadius: null);
+ Open(runtime, Vendor);
+
+ // 0.5 m: inside the 0.6 m fallback.
+ RuntimeVendorRangeQuery.EnforceRange(runtime);
+ Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
+
+ // Walk to 2 m: outside the 0.6 m fallback.
+ SetPosition(playerRecord, Landblock, 102.5f, 100f);
+ RuntimeVendorRangeQuery.EnforceRange(runtime);
+ Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
+ }
+
+ private static void Open(GameRuntime runtime, uint vendorGuid) =>
+ Assert.True(runtime.InventoryOwner.Vendor.Apply(
+ vendorGuid,
+ default,
+ Array.Empty()));
+
+ private static void SetPosition(
+ RuntimeEntityRecord record,
+ uint landblock,
+ float x,
+ float y,
+ float z = 5f)
+ {
+ record.Snapshot = record.Snapshot with
+ {
+ Position = new CreateObject.ServerPosition(
+ landblock, x, y, z, 1f, 0f, 0f, 0f),
+ };
+ }
+
+ private static GameRuntime Create()
+ {
+ var operations = new Operations();
+ return new GameRuntime(new GameRuntimeDependencies(
+ operations,
+ operations,
+ operations,
+ operations));
+ }
+
+ private static RuntimeEntityRecord Add(
+ GameRuntime runtime,
+ uint guid,
+ uint landblock,
+ float x,
+ float y,
+ float? useRadius = null)
+ {
+ RuntimeEntityRecord record = runtime.EntityObjects
+ .RegisterEntity(Spawn(guid, landblock, x, y, useRadius))
+ .Canonical!;
+ Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
+ record,
+ record.CreateIntegrationVersion,
+ record.Snapshot,
+ replaceGeneration: false));
+ return record;
+ }
+
+ private static WorldSession.EntitySpawn Spawn(
+ uint guid,
+ uint landblock,
+ float x,
+ float y,
+ float? useRadius) =>
+ new(
+ guid,
+ new CreateObject.ServerPosition(landblock, x, y, 5f, 1f, 0f, 0f, 0f),
+ 0x02000001u,
+ [],
+ [],
+ [],
+ null,
+ null,
+ guid.ToString("X8"),
+ null,
+ null,
+ null,
+ UseRadius: useRadius);
+
+ private sealed class Operations :
+ IRuntimeCombatAttackOperations,
+ IRuntimeCombatTargetOperations,
+ IRuntimeCombatModeOperations,
+ IRuntimeSpellCastOperations
+ {
+ public bool CanStartAttack() => false;
+ public void PrepareAttackRequest() { }
+ public bool SendAttack(AttackHeight height, float power) => false;
+ public void SendCancelAttack() { }
+ public bool IsDualWield => false;
+ public bool PlayerReadyForAttack => false;
+ public bool AutoRepeatAttack => false;
+ public bool AutoTarget => false;
+ public uint? SelectClosestTarget() => null;
+ public bool IsInWorld => false;
+ public IReadOnlyList GetOrderedEquipment() => [];
+ public void NotifyExplicitCombatModeRequest() { }
+ public void SendChangeCombatMode(CombatMode mode) { }
+ public uint LocalPlayerId => 0u;
+ public bool CanSend => false;
+ public bool HasRequiredComponents(uint spellId) => false;
+ public bool IsTargetCompatible(
+ uint targetId,
+ SpellMetadata spell,
+ bool showMessage) => false;
+ public void StopCompletely() { }
+ public void SendUntargeted(uint spellId) { }
+ public void SendTargeted(uint targetId, uint spellId) { }
+ public void DisplayMessage(string message) { }
+ public void IncrementBusy() { }
+ }
+}