diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index c0f081f8..e03a8756 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -160,7 +160,7 @@ readiness/requeue adaptation. See
---
-## 3. Documented approximation (AP) — 98 active rows (AP-140 filed 2026-08-04 at the Bug B Opus review — the accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT; the fix made that gate live and behaviour-visible, so it is now a filed divergence rather than an unremarked one; 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-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; 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) — 97 active rows (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-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; 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
@@ -240,7 +240,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` |
| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit |
-| AP-87 | **Remote MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07; unified across player-remote and NPC-remote by C4 route 4a, 2026-08-03 — retail's disassembly makes no `this==player` distinction here either, so the two formerly-duplicated per-kind copies are now the SAME decision): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. A third condition `firstUp` (`LastServerPosTime <= 0`) is RETAINED, not dropped, in the unified seam: it is a belt hint only — the 4 m guard is the load-bearing backstop — and it is structurally false for player remotes because the player-remote caller stamps `LastServerPosTime` in its diagnostic roll-forward block before it routes, so unifying the two copies on all three conditions leaves the player branch's own behaviour bit-identical | `src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs` (`ApplyInterpolate`, `BodySnapThreshold`); called from `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` for both the player-remote and NPC-remote near-Interpolate branches. C4 route 4b-2 (2026-08-04) deleted the App's two duplicated `MaxPhysicsDistance = 96f` / `BodySnapThreshold = 4f` constant pairs and both `_playerController?.Position ?? Vector3.Zero` fabrications: the far branch is now a canonical Runtime placement, and the cell-less/rejected/unclassified leftovers call this same seam (AP-137). The 4 m constant exists in exactly one place | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare. **2026-08-04 observed live, then FIXED AT THE SOURCE the same day**: the route 4a two-client test caught exactly this risk — a player remote jumping onto a house roof planted there and sat until it had drifted >4 m from the server's slid-down position, at which point this backstop fired (`producer=ap87-4m` in the capture) and blipped it instead of sliding. The CAUSE was the remote tick forging `Contact | OnWalkable` and deciding its landing edge from the contact-derived `ResolveResult.IsOnGround`; that is fixed (Bug B, `docs/ISSUES.md` #32) and the thresholds and conditions of this row are deliberately UNCHANGED. The snap remains the #184 invisible-but-solid backstop; it should simply fire far less often now that the body genuinely tracks the server | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
+| AP-87 | **Remote MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07; unified across player-remote and NPC-remote by C4 route 4a, 2026-08-03 — retail's disassembly makes no `this==player` distinction here either, so the two formerly-duplicated per-kind copies are now the SAME decision): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. A third condition `firstUp` (`LastServerPosTime <= 0`) is RETAINED, not dropped, in the unified seam: it is a belt hint only — the 4 m guard is the load-bearing backstop — and it is structurally false for player remotes because the player-remote caller stamps `LastServerPosTime` in its diagnostic roll-forward block before it routes, so unifying the two copies on all three conditions leaves the player branch's own behaviour bit-identical | `src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs` (`ApplyInterpolate`, `BodySnapThreshold`); called from `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` for both the player-remote and NPC-remote near-Interpolate branches. C4 route 4b-2 (2026-08-04) deleted the App's two duplicated `MaxPhysicsDistance = 96f` / `BodySnapThreshold = 4f` constant pairs and both `_playerController?.Position ?? Vector3.Zero` fabrications: the far branch is now a canonical Runtime placement, and the cell-less/rejected/unclassified leftovers call this same seam (AP-137). The 4 m constant exists in exactly one place | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare. **2026-08-04 observed live, then FIXED AT THE SOURCE the same day**: the route 4a two-client test caught exactly this risk — a player remote jumping onto a house roof planted there and sat until it had drifted >4 m from the server's slid-down position, at which point this backstop fired (`producer=ap87-4m` in the capture) and blipped it instead of sliding. The CAUSE was the remote tick forging `Contact | OnWalkable` and deciding its landing edge from the contact-derived `ResolveResult.IsOnGround`; that is fixed (Bug B, `docs/ISSUES.md` #32) and the thresholds and conditions of this row are deliberately UNCHANGED. The snap remains the #184 invisible-but-solid backstop; it should simply fire far less often now that the body genuinely tracks the server, and less often again since AP-140's same-day retirement pointed both routing gates at CONTACT: a steep-face slide now interpolates toward the server pose every packet instead of being classified free-flight and hard-snapped, so `bodyToTarget` converges rather than being left to drift past 4 m. This row's own thresholds and conditions are unchanged by either fix | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) |
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
| AP-92 | Private creature viewports (paperdoll and examination) render through isolated `IGpuRenderTarget`s and blit into `UiViewport`; retail renders each `CreatureMode` directly and advances a cloned `CPhysicsObj`, while examination currently refreshes its clone from the live target's animated mesh pose. **V6l narrowing (2026-07-28), V11 update (2026-07-29):** the target is backend-neutral and the blit's V origin is not assumed — `IUiViewportRenderer.TextureIsBottomUp` derives it from the backend that made the texture. With GL deleted the only answer in the tree is Vulkan's top-left origin, but the seam is kept rather than folded flat, because it costs one property and it is what let the origin question be answered by data instead of by assumption. | `src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs`; `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.App/UI/UiViewport.cs` | Vulkan render-to-texture is the modern backend equivalent; shared live pose data provides animation without registering a second gameplay entity or duplicating the world sequencer | Alpha, lighting, state isolation, or an assessment-time cloned motion diverging later from the live target can differ from direct retail CreatureMode presentation. The origin half of this risk is closed; a FUTURE backend would have to answer `TextureIsBottomUp` for itself | `CPhysicsObj::makeObject(CPhysicsObj const*) @ 0x005144B0`; `gmPaperDollUI::PostInit @ 0x004A5360`; `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
@@ -287,7 +287,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-137 | **Filed 2026-08-04 (C4 route 4b-2); rewritten same day at the dual Opus review.** acdream can classify a remote's accepted Position into three states retail cannot reach, and they now share ONE stated handler instead of a duplicated near/far block. The states: (a) **no classification at all** — `RuntimeAcceptedPositionRouteRequests.TryBuild` refuses to fabricate a local-player position, so `ClassifyRemoteAcceptedPosition` returns null for EVERY remote packet until the local movement controller exists (the login window) and whenever the canonical record has not claimed a local id; (b) **`RejectedAuthority`/`RejectedData`** — acdream validates wire authority and payload finiteness, retail validates neither; (c) the **cell-less `SetPosition`** half, which retail routes through `this_1->cell == 0` @0x00516386 and route 4b-3 will own. All three take AP-87's shared `ApplyInterpolate` catch-up (`RuntimeRemoteFarSnapPosition.ResolveArm`'s `UnroutedCatchUp`). **R1 — what the deleted far test actually computed (the first version of this row was wrong).** It claimed the deleted `_playerController?.Position ?? Vector3.Zero` distance had "no relationship to `player_distance`". Not true: `worldPos` is streaming-origin-relative (local position + `(landblock − _origin.Center) × 192 m`) and the streaming origin recentres on the local player's landblock, so the fabricated distance measured the remote's range from the ORIGIN LANDBLOCK'S CORNER — a biased but genuinely correlated proxy, error bounded by the player's own offset inside that landblock (0–192 m per axis). It is deleted anyway because a silently-biased proxy for an exact 96 m threshold is not a threshold: the bias reaches ~2.8× the threshold, so the arm it selects is frequently not the arm retail selects, and correcting it needs exactly the player position the classifier declined to fabricate. **R2 — the cell-less delta, stated.** Retail's cell-less arm is an UNCONDITIONAL placement sitting AHEAD of the contact test (`teleport_hook` @0x005163EF, `SetPosition` flags `0x1012` @0x00516420, `return 1` @0x00516438); acdream now ENQUEUES that classification whenever `!firstUp && willBeDrTicked && bodyToTarget <= 4 m`, at ANY distance, not only ≥96 m. **Deliberately not changed to place in 4b-2**: retail's arm is not a pose write, it is `teleport_hook` @0x00514ED0 — the COMPLETE call list, corrected at the 2026-08-04 delta review, which found the earlier enumeration had dropped the last entry: `MovementManager::CancelMoveTo` @0x00514EDF, `PositionManager::UnStick` @0x00514EEE, `PositionManager::StopInterpolating` @0x00514EFD, `PositionManager::UnConstrain` @0x00514F0C, `TargetManager::ClearTarget` @0x00514F1B + `TargetManager::NotifyVoyeurOfEvent(Teleported_TargetStatus)` @0x00514F28, and `CPhysicsObj::report_collision_end(this, 1)` @0x00514F31 — followed by the canonical flags-`0x1012` `SetPosition` — the classifier itself records this as `TeleportHookPhase.BeforePositionOperation`. Writing only the pose would leave a live moveto, a live stick, and a leash anchored to the old cell, strictly worse than the queue. Porting the whole arm is route 4b-3's entire scope. **R3 — `RejectedData` is APPLIED anyway.** It is the one classification meaning "this payload failed validation" (`ClassifyAcceptedPosition` emits it for a `ValidPosition` failure and for a non-finite/negative derived `player_distance`), and `UnroutedCatchUp` hands the same payload to `ApplyInterpolate`. Not a regression — the legacy block did the same — but the slice's stated purpose was an explicit handler, so it is named. **Headless (contract item 6) is satisfied vacuously and that is stated, not implied:** nothing in `AcDream.Headless` constructs `RuntimeRemotePlacementDriveController` (`SessionPlayerComposition` is the only construction site) and `RuntimeLiveEntitySessionController.OnPositionUpdated` returns early for every non-local GUID, so the far snap is a graphical-host-only path | `src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs` (`ResolveArm`, `RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp`); applied at `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyRemoteContactRouting`'s default arm); headless statement on `IRuntimeRemotePlacementServiceWindow` | AP-87's own snap conditions (`firstUp \|\| !willBeDrTicked \|\| bodyToTarget > 4 m`) still PLACE an unplaced or badly-lagging body, so a remote keeps tracking the server through the login window and through a rejected packet — this arm is never frozen. The deleted far test could not have been preserved honestly: one of its three residual inputs is an uncorrectably biased fabrication, one is unreachable (the classifier returns before evaluating distance), and the third contradicted retail's own branch order | A leftover-classified remote beyond 96 m that is already tracking catches up over a packet interval instead of snapping — invisible in practice at that range, but a real change to the cell-less path that route 4b-3 must re-check when it takes ownership, together with the two rejections' own arm. If AP-87's 4 m backstop were ever weakened, this arm would become the silent-freeze path the route 4b scoping named as its trap | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x00516386 cell-0/teleport, @0x005163AF near, @0x005163C1-E8 far); `CPhysicsObj::teleport_hook` @0x00514ED0; `RuntimeAcceptedPositionRouteRequests.TryBuild`; `GameRuntime.cs:288-290` (the no-fabricated-Vector3.Zero rule) |
| AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm names the destination — measured 2026-08-04 at round 3. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and `RemoteTeleportController`'s rollback is a shipped writer that rebuckets it back to the PRE-teleport landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) |
| AP-139 | **Filed 2026-08-04 (Bug B).** The remote tick clears its InterpolationManager queue on the LANDING edge — retail’s own `set_on_walkable(1)` transition, the same edge HitGround fires from. Retail has no such clear on a ground or contact edge: its only queue teardown outside a completed walk is `PositionManager::StopInterpolating` from `CPhysicsObj::teleport_hook` @0x00514EFD and the `InterpolationManager::UseTime` @0x00555f20 stall/autonomy blips. The clear is carried over unchanged in intent from the deleted hand-rolled landing block (#184, 2026-07-07), which hung it on a hand-rolled `Airborne && IsOnGround && Velocity.Z <= 0` test that also fired on a steep (non-walkable) contact; Bug B re-derived the edge without changing the behaviour it was written for | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` (the SetPositionInternal commit block); the packet-side twin lives in `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the player-remote landing snap) | A contact-free arc never enqueues — route 4a's airborne no-op writes nothing at all — so anything still queued when the body lands is a pre-arc waypoint, and the first catch-up after touchdown would otherwise walk the body backward toward it | A remote that regains contact while a legitimately fresh waypoint is queued loses one correction and re-acquires it on the next accepted Position (~5-10 Hz). A body that repeatedly loses and regains contact (a bounce chain down a rough face) clears the queue once per bounce. Retire when the arc itself feeds the queue, at which point the pre-arc waypoints are no longer stale | `CPhysicsObj::teleport_hook @ 0x00514ED0` (`StopInterpolating` @0x00514EFD); `InterpolationManager::UseTime @ 0x00555f20`; `CPhysicsObj::SetPositionInternal @ 0x00515330` |
-| AP-140 | **Filed 2026-08-04 (Bug B Opus review).** The two gates that route an accepted server Position for a remote — `ApplyRemoteContactRouting`'s `if (remote.Airborne)` airborne-snap carve-out, and the player-remote landing block's `if (rmState.Airborne)` in `OnPosition` — choose between retail's near-`InterpolateTo` arm and a hard snap using the client `Airborne` flag, which is `!Body.OnWalkable`, i.e. WALKABILITY. **Retail's predicate for exactly that decision is CONTACT**: `InterpolationManager::adjust_offset` @0x00555D30 gates its entire body on `transient_state & 1` @0x00555D52, so a retail body in contact with a NON-walkable face still walks toward its queued waypoint. The two predicates disagree on precisely one state — in contact, not on walkable ground — and Bug B (2026-08-04) turned that state from unreachable into ordinary: the deleted per-tick `TransientState \|= Contact \| OnWalkable` forge previously made every non-airborne remote walkable by construction, so nothing could occupy it; a remote sliding on a steep roof now does, and is classified airborne and hard-snapped at UpdatePosition cadence instead of interpolated. Distinct from AP-87 (that row is the 4 m threshold on the interpolate arm) and from AP-135 (that row is the airborne no-op's retained bookkeeping, not the predicate that selects it) | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyRemoteContactRouting`, the `remote.Airborne` carve-out; `OnPosition`, the player-remote landing block), both reading `RemoteMotion.Airborne` — written at five sites, all spelling `!Body.OnWalkable`: `SettleSpawnedRemoteContact` and `RemoteTeleportPlacement.Apply` in App, `RuntimeSetPositionState`'s canonical placement commit, and `RuntimeRemotePhysicsUpdater`'s SetPositionInternal commit + `TickHidden` resolve | The disagreeing state is transient by nature — a body on a non-walkable face is sliding, and within a few ticks either reaches walkable ground or leaves contact — and the snap it takes is to the AUTHORITATIVE server position, so the error is bounded by one UpdatePosition interval (~5-10 Hz) and never accumulates. Bug B also made the between-packet motion a genuine local slide instead of a freeze, so the composite reads as continuous rather than frozen-then-teleported | A remote on a long steep face renders as a sequence of ~5-10 Hz position snaps rather than smooth interpolation — stepping/stutter on roofs, cliff faces, and steep terrain, worse the longer the slide. It also feeds AP-87: a snapped body never converges its `bodyToTarget` by interpolation, so the 4 m backstop stays relevant. **Next slice, re-shaped at this review: do NOT re-derive `Airborne` from CONTACT.** That perturbs all five writers and contradicts a pinned assertion in `RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_RestoresSourceWalkabilityForFirstAcceleration` (`InContact: true, OnWalkable: false` → `Assert.True(remote.Airborne)`). Change the TWO ROUTING GATES to read `remote.Body.InContact` directly and leave `Airborne` alone: strictly smaller, the literal retail predicate, touches no existing test. Retire this row when that lands | `InterpolationManager::adjust_offset @ 0x00555D30` (CONTACT gate @0x00555D52); `CPhysicsObj::SetPositionInternal @ 0x00515330` (CONTACT_TS @0x00515430 vs ON_WALKABLE_TS @0x00515465-0x0051548E); `CPhysicsObj::MoveOrTeleport @ 0x00516330` |
## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
index ae430796..8cab4a47 100644
--- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
+++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
@@ -945,14 +945,22 @@ internal sealed class LiveEntityNetworkUpdateController
/// arm selection testable and must not be collapsed to a bool.
internal enum RemoteContactArm : byte
{
- /// The body was airborne. Hard-snapped, exactly as before
- /// route 4a. Gated on remote.Airborne ALONE — the wire contact
- /// bit is never read here. The common case is the landing packet, but
- /// a not-in-contact packet also reaches this arm whenever the
- /// classification is one route 4a does not own (null, cell-less
+ /// The body was in free flight — NOT in contact with any
+ /// surface. Hard-snapped. Gated on the body's own
+ /// Body.InContact ALONE (AP-140, retired 2026-08-04) — the wire
+ /// contact bit is never read here. The common case is the landing
+ /// packet, but a not-in-contact packet also reaches this arm whenever
+ /// the classification is one route 4a does not own (null, cell-less
/// SetPosition, or rejected), because the
/// IsAirborneNoOperation early return fires only for
- /// classifications it does own. That matches pre-4a behaviour.
+ /// classifications it does own. That matches pre-4a behaviour.
+ ///
+ /// The name is retained for continuity with route 4a; read
+ /// "airborne" here as retail's CONTACT_TS-clear, not as the client
+ /// RemoteMotion.Airborne flag, which is
+ /// !Body.OnWalkable — a strictly WIDER set. The two disagree on
+ /// a body in contact with a non-walkable face (a steep slide), which
+ /// retail interpolates.
AirborneSnap,
/// Route 4a's near InterpolateTo branch.
@@ -1008,7 +1016,7 @@ internal sealed class LiveEntityNetworkUpdateController
/// packet classifies Interpolate, so if the 4a test came first it
/// would ENQUEUE a body that must PLANT, and a creature knocked off a
/// ledge would glide down over a packet interval. The player-remote caller
- /// reaches this method only with Airborne == false (its landing
+ /// reaches this method only with Body.InContact == true (its landing
/// block sits ahead of its routing and returns), so the carve-out is inert
/// there and the two callers stay one decision.
///
@@ -1046,7 +1054,29 @@ internal sealed class LiveEntityNetworkUpdateController
// TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family.
AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution(
canonical.ServerGuid);
- if (remote.Airborne)
+ // AP-140 (retired 2026-08-04): retail's predicate for exactly this
+ // decision is CONTACT, not walkability.
+ // `InterpolationManager::adjust_offset` @0x00555D30 gates its ENTIRE
+ // body on `physics_obj->transient_state & 1` @0x00555D52 — and
+ // TransientState bit 0 is `CONTACT_TS` (acclient.h:3690), NOT
+ // `ON_WALKABLE_TS` (0x2). A retail body in contact with a non-walkable
+ // face therefore still walks toward its queued waypoint. This gate used
+ // to read `remote.Airborne`, which is `!Body.OnWalkable` — WALKABILITY,
+ // a strictly wider set. The two disagree on precisely one state (in
+ // contact, not on walkable ground), and Bug B (`204d0ae0`) turned that
+ // state from unreachable into ordinary by deleting the per-tick
+ // `TransientState |= Contact | OnWalkable` forge that had made every
+ // non-airborne remote walkable by construction. A remote sliding on a
+ // steep roof lives in it, and was being hard-snapped at UpdatePosition
+ // cadence instead of interpolated.
+ //
+ // `Airborne` itself is deliberately NOT re-derived from CONTACT: it has
+ // five writers, all spelling `!Body.OnWalkable`, and the per-tick
+ // updater's ground-clamp branch plus
+ // `RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_…`
+ // both depend on the walkability reading. Only the two ROUTING gates
+ // move (this one and `OnPosition`'s player-remote landing block).
+ if (!remote.Body.InContact)
{
// Verbatim from the pre-4a branch, queue deliberately NOT
// cleared: the arc integrates locally (K-fix15), and clearing
@@ -1059,19 +1089,16 @@ internal sealed class LiveEntityNetworkUpdateController
// review round.
//
// Do NOT restate this as "the queue is already empty here" — it
- // is not. Nothing that sets Airborne clears the queue except the
- // teleport hook's StopInterpolating: neither the 0xF74E
- // VectorUpdate (OnVector, below) nor any of the five
- // `Airborne = !Body.OnWalkable` sites do. Those five are
- // SettleSpawnedRemoteContact (this file),
- // RemoteTeleportPlacement.Apply,
- // RuntimeSetPositionState's canonical placement commit, and
- // RuntimeRemotePhysicsUpdater's two — the SetPositionInternal
- // commit in Tick and the TickHidden resolve. A walking NPC can
- // enqueue a near waypoint and then step off a lip, arriving here
- // with a populated queue. That is exactly why the landing clear
- // exists, and a reader who believes the queue is empty here could
- // delete it.
+ // is not. Nothing that CLEARS the body's CONTACT bit clears the
+ // queue: it is dropped by the per-tick sweep's SetPositionInternal
+ // commit (and by TickHidden's resolve) whenever the contact plane
+ // stops being valid, and by nothing else — the teleport hook's
+ // StopInterpolating is the only thing that empties the queue on a
+ // leave-ground edge, and the 0xF74E VectorUpdate (OnVector, below)
+ // does not. A walking NPC can enqueue a near waypoint and then step
+ // off a lip, arriving here with a populated queue. That is exactly
+ // why the landing clear exists, and a reader who believes the queue
+ // is empty here could delete it.
remote.Body.Position = worldPos;
remote.Body.Orientation = rotation;
return new RemoteContactRouting(
@@ -2145,16 +2172,32 @@ internal sealed class LiveEntityNetworkUpdateController
}
// ── LANDING TRANSITION ────────────────────────────────────────
- // First IsGrounded=true UP while the client still considers the
- // body airborne (`!Body.OnWalkable`, now derived by the per-tick
- // SetPositionInternal commit rather than latched here).
+ // First IsGrounded=true UP while the body is still in FREE
+ // FLIGHT — not in contact with any surface (`!Body.InContact`,
+ // derived by the per-tick SetPositionInternal commit from the
+ // sweep's contact plane).
// Hard-snap to the authoritative landing position and clear the
- // interpolation queue (an airborne remote's Positions hard-snap
- // and never enqueue, so any pre-arc waypoints are stale).
+ // interpolation queue (a free-flying remote's Positions
+ // hard-snap and never enqueue, so any pre-arc waypoints are
+ // stale).
// `rmState.Airborne` is deliberately NOT cleared here: the next
// tick derives it from the sweep, which is the only thing that
// can tell walkable ground from a steep face.
//
+ // AP-140 (retired 2026-08-04): this gate read `rmState.Airborne`
+ // (`!Body.OnWalkable` — WALKABILITY). Retail's predicate for
+ // exactly this snap-vs-interpolate decision is CONTACT:
+ // `InterpolationManager::adjust_offset` @0x00555D30 gates its
+ // entire body on `transient_state & 1` @0x00555D52, and bit 0 is
+ // `CONTACT_TS` (acclient.h:3690), not `ON_WALKABLE_TS` (0x2). A
+ // remote in contact with a NON-walkable face — sliding down a
+ // steep roof, ordinary since Bug B `204d0ae0` deleted the
+ // per-tick walkability forge — was taking this hard snap at
+ // UpdatePosition cadence; it now falls through to the grounded
+ // routing below and interpolates, as retail does. See the twin
+ // gate in ApplyRemoteContactRouting for why `Airborne` itself is
+ // left alone.
+ //
// Bug B (2026-08-04) — the twin of the per-tick forge. This
// block used to additionally zero the body velocity, assert
// `Contact | OnWalkable`, invoke MovementManager::HitGround, and
@@ -2173,7 +2216,7 @@ internal sealed class LiveEntityNetworkUpdateController
// • retail never toggles GRAVITY_PS on a ground edge — see the
// per-tick commit's comment.
// What remains is AP-87's acdream-only snap, unchanged.
- if (rmState.Airborne)
+ if (!rmState.Body.InContact)
{
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
@@ -2269,8 +2312,8 @@ internal sealed class LiveEntityNetworkUpdateController
// is the SAME shared entry point the NPC arm below calls —
// retail's disassembly makes no `this == player` distinction
// on any of these branches. The player arm reaches it only
- // with Airborne == false (the landing block above returns), so
- // the airborne carve-out inside is inert here.
+ // with Body.InContact == true (the landing block above
+ // returns), so the free-flight carve-out inside is inert here.
bool willBeDrTicked = WillAdvanceRemoteMotion(update.Guid, rmState);
RemoteContactRouting playerRouting = ApplyRemoteContactRouting(
_remotePlacementDrive,
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs
index 5d319a2f..7d853f0e 100644
--- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs
@@ -119,6 +119,48 @@ public sealed class LiveEntityNetworkBranchRoutingTests
source);
}
+ ///
+ /// AP-140 (retired 2026-08-04): the SECOND accepted-Position routing
+ /// gate — OnPosition's player-remote landing block — must
+ /// select the hard snap on retail's CONTACT predicate
+ /// (InterpolationManager::adjust_offset @0x00555D30 gates its
+ /// whole body on transient_state & 1 @0x00555D52, and bit 0
+ /// is CONTACT_TS), not on the client Airborne flag,
+ /// which is !Body.OnWalkable — WALKABILITY, a strictly wider
+ /// set that also captures a remote sliding on a steep face.
+ ///
+ ///
+ /// A source pin rather than a behavioural fixture for the reason this
+ /// class already documents: the controller's dependency set is
+ /// composition-only. The twin gate inside
+ /// ApplyRemoteContactRouting — a static method, so reachable —
+ /// IS covered behaviourally, in
+ /// LiveEntityNetworkRemoteSteadyStateIntegrationTests. Restore
+ /// if (rmState.Airborne) here and this test fails.
+ ///
+ ///
+ [Fact]
+ public void PlayerRemoteLandingSnapSelectsOnContactNotOnWalkability()
+ {
+ string source = ReadSource("LiveEntityNetworkUpdateController.cs");
+
+ Assert.Contains(
+ "if (!rmState.Body.InContact)",
+ source,
+ StringComparison.Ordinal);
+ // `rmState.Airborne` survives as a WRITE target and in prose (the
+ // block's own comment explains why it is deliberately not cleared
+ // there); what must never come back is reading it as the gate.
+ Assert.DoesNotContain(
+ "if (rmState.Airborne)",
+ source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "if (remote.Airborne)",
+ source,
+ StringComparison.Ordinal);
+ }
+
private static string ReadSource(string fileName)
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs
index fe88e195..23f41196 100644
--- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs
@@ -392,7 +392,13 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests
fixture.AllowDestination();
(RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) =
fixture.AddRemote(0x70004008u, Destination);
+ // AP-140 (retired 2026-08-04): "mid-arc" is retail's CONTACT_TS-clear
+ // (`InterpolationManager::adjust_offset` gates on `transient_state & 1`
+ // @0x00555D52), not the client `Airborne` walkability flag. Both are
+ // set here so the test states free flight in the terms the gate now
+ // reads AND the terms it used to.
remote.Airborne = true;
+ remote.Body.TransientState = TransientStateFlags.Active;
var target = new Vector3(60f, 10f, 7f);
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs
index d254079f..8bcce1f9 100644
--- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs
@@ -161,7 +161,12 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests
(RuntimeEntityRecord record, RemoteMotion remote, _) =
fixture.AddRemote(0x70005001u, new Vector3(12f, 14f, 7f));
remote.Body.Position = new Vector3(10f, 10f, 5f);
+ // AP-140 (retired 2026-08-04): free flight is retail's CONTACT_TS-clear
+ // (`InterpolationManager::adjust_offset` gates on `transient_state & 1`
+ // @0x00555D52), not the client `Airborne` walkability flag. Both are
+ // stated so the test reads the same before and after that change.
remote.Airborne = true;
+ remote.Body.TransientState = TransientStateFlags.Active;
var landing = new Vector3(10.5f, 10f, 5f); // 0.5 m — well within 4 m.
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
@@ -211,6 +216,137 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests
Assert.Equal(new Vector3(10f, 10f, 5f), remote.Body.Position);
}
+ // ── AP-140 (retired 2026-08-04): the gate's predicate is CONTACT ────────
+
+ ///
+ /// The divergence AP-140 recorded, as behaviour. A remote in CONTACT with
+ /// a NON-walkable face — sliding down a steep roof — is
+ /// Airborne == true by the client flag's own definition
+ /// (!Body.OnWalkable), and used to be hard-snapped at
+ /// UpdatePosition cadence. Retail interpolates it:
+ /// InterpolationManager::adjust_offset @0x00555D30 gates its entire
+ /// body on transient_state & 1 @0x00555D52, and bit 0 is
+ /// CONTACT_TS (acclient.h:3690), not ON_WALKABLE_TS (0x2).
+ ///
+ ///
+ /// This is the ONE state on which the two predicates disagree, so it is
+ /// the only test that can discriminate the fix: point the gate back at
+ /// remote.Airborne and this test alone fails, with
+ /// AirborneSnap and a moved body.
+ ///
+ ///
+ [Fact]
+ public void SteepContactBody_InterpolatesInsteadOfSnapping()
+ {
+ using var fixture = new RemotePlacementDriveFixture();
+ (RuntimeEntityRecord record, RemoteMotion remote, _) =
+ fixture.AddRemote(0x70005003u, new Vector3(12f, 14f, 7f));
+ var before = new Vector3(10f, 10f, 5f);
+ remote.Body.Position = before;
+ // Contact with a face too steep to stand on: CONTACT set, ON_WALKABLE
+ // clear — exactly what the sweep's SetPositionInternal commit derives
+ // on a 52-degree roof since Bug B (204d0ae0) deleted the per-tick
+ // walkability forge that used to make this state unreachable.
+ remote.Body.TransientState =
+ TransientStateFlags.Active | TransientStateFlags.Contact;
+ Assert.True(remote.Body.InContact);
+ Assert.False(remote.Body.OnWalkable);
+ // The client flag agrees with its five writers, all `!Body.OnWalkable`.
+ remote.Airborne = !remote.Body.OnWalkable;
+ Assert.True(remote.Airborne);
+
+ LiveEntityNetworkUpdateController.RemoteContactRouting routing =
+ LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
+ fixture.Drive,
+ record,
+ remote,
+ Classify(hasContact: true, playerDistance: 10f),
+ before + new Vector3(0.5f, 0f, 0f),
+ Quaternion.Identity,
+ willBeDrTicked: true);
+
+ Assert.Equal(
+ LiveEntityNetworkUpdateController.RemoteContactArm
+ .SteadyStateInterpolate,
+ routing.Arm);
+ // The interpolate arm enqueues a waypoint and leaves the body to the
+ // per-tick catch-up; the snap arm would have written it directly.
+ Assert.Equal(before, remote.Body.Position);
+ }
+
+ ///
+ /// The regression guard on the other side of the same gate: a genuinely
+ /// free-flying remote — no contact with anything — must still take the
+ /// hard snap. Retail's adjust_offset writes nothing at all with
+ /// CONTACT_TS clear, so the queued waypoint cannot advance the
+ /// body and the authoritative position is the only thing that can.
+ /// Widen the gate to "always interpolate" and this test fails.
+ ///
+ [Fact]
+ public void FreeFlightBodyWithNoContact_StillSnaps()
+ {
+ using var fixture = new RemotePlacementDriveFixture();
+ (RuntimeEntityRecord record, RemoteMotion remote, _) =
+ fixture.AddRemote(0x70005004u, new Vector3(12f, 14f, 7f));
+ remote.Body.Position = new Vector3(10f, 10f, 5f);
+ remote.Body.TransientState = TransientStateFlags.Active;
+ Assert.False(remote.Body.InContact);
+ remote.Airborne = !remote.Body.OnWalkable;
+ var landing = new Vector3(10.5f, 10f, 5f);
+
+ LiveEntityNetworkUpdateController.RemoteContactRouting routing =
+ LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
+ fixture.Drive,
+ record,
+ remote,
+ Classify(hasContact: true, playerDistance: 10f),
+ landing,
+ Quaternion.Identity,
+ willBeDrTicked: true);
+
+ Assert.Equal(
+ LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap,
+ routing.Arm);
+ Assert.Equal(landing, remote.Body.Position);
+ }
+
+ ///
+ /// The unchanged third state: contact with WALKABLE ground. Both
+ /// predicates agree here, and both before and after AP-140's fix the
+ /// packet interpolates. Pinned so a future edit cannot "simplify" the
+ /// gate into something that only satisfies the two tests above.
+ ///
+ [Fact]
+ public void WalkableGroundedBody_IsUnchangedAndStillInterpolates()
+ {
+ using var fixture = new RemotePlacementDriveFixture();
+ (RuntimeEntityRecord record, RemoteMotion remote, _) =
+ fixture.AddRemote(0x70005005u, new Vector3(12f, 14f, 7f));
+ var before = new Vector3(10f, 10f, 5f);
+ remote.Body.Position = before;
+ remote.Body.TransientState = TransientStateFlags.Active
+ | TransientStateFlags.Contact
+ | TransientStateFlags.OnWalkable;
+ remote.Airborne = !remote.Body.OnWalkable;
+ Assert.False(remote.Airborne);
+
+ LiveEntityNetworkUpdateController.RemoteContactRouting routing =
+ LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
+ fixture.Drive,
+ record,
+ remote,
+ Classify(hasContact: true, playerDistance: 10f),
+ before + new Vector3(0.5f, 0f, 0f),
+ Quaternion.Identity,
+ willBeDrTicked: true);
+
+ Assert.Equal(
+ LiveEntityNetworkUpdateController.RemoteContactArm
+ .SteadyStateInterpolate,
+ routing.Arm);
+ Assert.Equal(before, remote.Body.Position);
+ }
+
private static void AssertRenderPoseSuppressed(
RuntimeAuthoritativePositionRoute? route)
{
diff --git a/tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs b/tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs
index 8938cace..d8e47ef9 100644
--- a/tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs
+++ b/tests/AcDream.App.Tests/Physics/RemotePlacementDriveFixture.cs
@@ -96,7 +96,21 @@ internal sealed class RemotePlacementDriveFixture : IDisposable
Orientation = Quaternion.Identity,
LastUpdateTime = 1d,
State = PhysicsStateFlags.Gravity,
- TransientState = TransientStateFlags.Active,
+ // AP-140 (retired 2026-08-04): the remote stands on the flat
+ // source landblock, so the sweep's SetPositionInternal commit
+ // would derive CONTACT (`contact_plane_valid` @0x00515430) and
+ // ON_WALKABLE (`contact_plane.N.z >= floor_z`
+ // @0x00515465-0x0051548E) for it. Both bits now have to be
+ // present, because the accepted-Position routing gates read
+ // retail's CONTACT predicate
+ // (`InterpolationManager::adjust_offset` @0x00555D52) rather than
+ // the client `Airborne` walkability flag: a body with a bare
+ // `Active` transient state is in FREE FLIGHT and every routing
+ // test would take the free-flight snap arm. Tests that want free
+ // flight clear these explicitly.
+ TransientState = TransientStateFlags.Active
+ | TransientStateFlags.Contact
+ | TransientStateFlags.OnWalkable,
};
body.SnapToCell(SourceCell, body.Position, body.Position);
Lifetime.Entities.SetPhysicsBody(record, body);