From 204d0ae0474e37afc08f789f7f2b6572216cb1ca Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Aug 2026 10:21:16 +0200 Subject: [PATCH] fix(physics): remote bodies slide on steep faces instead of freezing (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote observed in acdream landed on a sloped roof and froze; the server slid on, the gap passed AP-87's 4 m threshold, and the body snapped — the visible blip. Live probe capture, two adjacent ticks 63 ms apart: t=88420671 rsInContact=True rsOnWalkable=False rsIsOnGround=True bodyCpNz=0.6097 floorZ=0.6642 steep=True gravity=True vel=(2.146,2.264,-3.549) t=88420734 contact=True onWalkable=True <- forced against the sweep gravity=False <- cleared velBeforeZero=(2.146,2.264,0.000) moved=0.0000 <- and every tick after The roof is 52.4 degrees against a 48.4 degree limit, so acdream's classifier was CORRECT and was then overruled. Four independent links each froze the body on their own: a per-tick force of Contact|OnWalkable, a per-tick velocity zero, a Gravity clear at landing, and a landing edge testing IsOnGround (= inContact || ...) instead of OnWalkable. The tick called HandleAllCollisions alone — the tail of SetPositionInternal without its prefix. Retail simulates remotes locally and derives these bits rather than asserting them: CPhysics::UseTime @0x00509950 iterates the whole object table; update_object @0x00515D10 gates only on parent/cell/FROZEN with no is_player fork; SetPositionInternal @0x00515330 sets CONTACT from contact_plane_valid @0x00515430 and ON_WALKABLE from contact_plane.N.z vs floor_z @0x00515465-@0x0051548E before handle_all_collisions @0x005154FE; set_on_walkable @0x00511310 fires HitGround @0x00511364 / LeaveGround @0x00511346 edge-triggered with no ownership gate; calc_acceleration @0x00510950 zeroes only when CONTACT && ON_WALKABLE && !Sledding @0x0051096B; calc_friction @0x0050EE70 returns at its first line when ON_WALKABLE is clear. acdream had copied retail's airborne no-op WITHOUT retail's local simulation. The fix is mostly deletion: stop forging the transients, stop discarding the authoritative velocity, stop clearing Gravity, and route the remote tick through the same SetPositionInternal commit TickHidden and the local player already use, with the landing edge derived from the sweep's own OnWalkable. AP-87's threshold and conditions and InterpolationManager's node_fail_counter snap-to-tail are deliberately untouched — this removes the CAUSE of the divergence rather than weakening the backstop. Cross-checked against ACE: its only creature-side VectorUpdate emitters are the jump broadcast and spell projectiles, so integrating the wire velocity cannot double-move a walking remote; and PhysicsGlobals.DefaultState already carries Gravity, so deleting the manufactured State |= Gravity is safe. Register: AP-81 narrowed (its GRAVITY half retired outright), AP-87 annotated, AP-139 filed (the interpolation-queue clear on the landing edge), AP-140 filed (the two routing gates select snap-vs-interpolate on walkability where retail uses CONTACT — adjust_offset @0x00555D30 gates on transient_state & 1 @0x00555D52). AP-140's follow-up is deliberately shaped as "point the two gates at Body.InContact", NOT "re-derive Airborne", which would perturb five writers and collide with a pinned RemoteTeleportPlacementTests assertion. Three gaps recorded in #32 rather than papered over: the new LeaveGround dispatch is untested for chatter; a persistently !Ok transition can latch a remote airborne; and — the visual-gate watch item — the deleted forge was a blanket guarantee of Contact|OnWalkable, and contact_allows_move @0x00528dd0 silently refuses action animations without both, which is the literal root cause of closed #270. Retail-correct on a steep face, a regression anywhere else. 10 discriminating tests over a real PhysicsEngine landblock whose contact normal Z is 0.61 against FloorZ 0.6642 — the live roof's exact relationship. Suite 11,019 passed / 4 skipped / 0 failed. Includes the temporary ACDREAM_PROBE_REMOTE_LANDING / ACDREAM_PROBE_REMOTE_SLIDE probe family that produced the capture above; strip with the family. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 157 +++- .../retail-divergence-register.md | 8 +- ...2026-08-04-bug-a-h3-scheduler-diagnosis.md | 559 ++++++++++++++ ...2026-08-04-bug-b-remote-slide-diagnosis.md | 698 ++++++++++++++++++ .../LiveEntityNetworkUpdateController.cs | 250 +++++-- .../Physics/InterpolationManager.cs | 28 + .../Physics/Motion/MotionTableDispatchSink.cs | 6 + .../Physics/PhysicsDiagnostics.cs | 496 +++++++++++++ .../Physics/RuntimeRemotePhysicsUpdater.cs | 580 ++++++++++----- .../RuntimeRemoteSteadyStatePosition.cs | 48 ++ .../RuntimeRemoteSteepContactSlideTests.cs | 518 +++++++++++++ 11 files changed, 3103 insertions(+), 245 deletions(-) create mode 100644 docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md create mode 100644 docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteepContactSlideTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 2d6d85ce..b8e5e7b8 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -9609,12 +9609,161 @@ other pieces that are either incomplete or unverified for remotes: contact-plane-derived slide, not the terrain-only approximation AD-10 already flags as a divergence. -No fix has been applied for this observation — the investigation stopped -here per project policy (no workarounds without approval) and instrumented a -probe (`ACDREAM_PROBE_REMOTE_LANDING`, `PhysicsDiagnostics.cs`) instead. See +The investigation stopped there per project policy and instrumented a probe +(`ACDREAM_PROBE_REMOTE_LANDING`, then the `[remote-slide-*]` family in +`PhysicsDiagnostics.cs`). See `docs/research/2026-08-04-remote-landing-investigation.md` for the companion Bug A (falling-animation-lingers) hypothesis set and the probe's decision -table. +table, and `docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md` for the +full four-link chain. + +**Bug B FIXED 2026-08-04 (awaiting the user's two-client visual gate).** The +live capture settled it: two adjacent ticks 63 ms apart on a 52.4-degree roof +(contact-plane `Normal.Z` 0.6097 against `FloorZ` 0.6642) showed the sweep +reporting `rsInContact=True rsOnWalkable=False`, and the tick then committing +`contact=True onWalkable=True gravity=False velBeforeZero=(2.146,2.264,0.000)` +and `moved=0.0000` on every tick afterwards. **acdream's classifier was +correct and was being overruled.** Four independent writes did it, and all four +are gone: + +1. `RuntimeRemotePhysicsUpdater.Tick` asserted + `TransientState |= Contact | OnWalkable` on every tick a remote was not + flagged airborne. Retail writes CONTACT_TS from + `collision_info.contact_plane_valid` (`CPhysicsObj::SetPositionInternal` + @0x00515330, 0x00515430) and routes ON_WALKABLE_TS through + `set_on_walkable` (@0x00511310) purely on + `contact_plane.N.z >= PhysicsGlobals::floor_z` (0x00515465-0x0051548E). + With both bits forced, `calc_acceleration` (@0x00510950) returned zero + acceleration and `calc_friction` (@0x0050EE70) — whose entire body sits + inside `transient_state & 2` — could not engage either. +2. The same block zeroed `Body.Velocity`. Retail's `MoveOrTeleport` + (@0x00516330) never reads or writes a remote's wire velocity at all; the + zeroing discarded both the authoritative `0xF74E` vector and everything + gravity had accumulated. +3. The tick consumed only `ResolveResult.Position/CellId/IsOnGround` and called + `HandleAllCollisions` bare — the TAIL of `SetPositionInternal` without its + prefix. The sweep's own `InContact`/`OnWalkable` were never committed, and + the landing edge was decided from `IsOnGround`, which is `inContact || ...` + and is therefore TRUE on a steep contact. The tick now runs the full + `PhysicsObjUpdate.CommitSetPositionTransition` sequence (contact prefix -> + `set_on_walkable` edge -> `handle_all_collisions` @0x005154FE), gated on + `Ok && candidateMoved` exactly like `PlayerMovementController` and retail + `UpdateObjectInternal` (pc:283657). +4. Both landing blocks cleared `PhysicsStateFlags.Gravity`. Retail never + toggles GRAVITY_PS on a ground edge: the `CPhysicsObj` constructor seeds it + (state `0x400C08` @0x00512508) and `set_state` (@0x00514DD0) assigns the + description's state wholesale, post-processing only lighting/nodraw/hidden. + The matching `State |= Gravity` in the VectorUpdate jump handler is deleted + too, so the bit is now wire-owned end to end. + +Consequential cleanups in the same change: `MovementManager::HitGround` now has +the single retail source it has in the binary — the `set_on_walkable(1)` +edge — so the packet-side landing block no longer dispatches its own +(which would have double-fired the landing re-apply); and `RemoteMotion.Airborne` +is now derived on the SetPositionInternal commit from the committed +`Body.OnWalkable`, which is the project's one existing definition of the flag +(`PlayerMovementController.IsAirborne`, the spawn settle, +`RemoteTeleportPlacement`, `TickHidden`). Only the fact it derives FROM moved, +from the hand-rolled `IsOnGround` test to the sweep's contact-plane result. + +**Known follow-up, deliberately not changed here — now register row AP-140.** +Because `Airborne` remains `!OnWalkable`, a remote sliding on a steep face is +classified airborne, so an accepted grounded Position takes the +`AirborneSnap`/landing-snap arm and hard-snaps to the server position at UP +cadence instead of feeding the interpolation queue. Retail's predicate for that +same decision is the CONTACT transient, not walkability +(`InterpolationManager::adjust_offset` @0x00555D30 gates its whole body on +`transient_state & 1` @0x00555D52), so a retail body in contact with a +non-walkable face keeps interpolating. The two disagree on exactly one state, +and this fix turned that state from unreachable (the deleted forge made every +non-airborne remote walkable by construction) into ordinary — which is why it +is now a filed divergence rather than an unremarked one. The bound is one UP +interval to the authoritative position, and the between-packet motion is now a +genuine local slide rather than a freeze, so the composite reads as continuous. + +**The follow-up slice is re-shaped (2026-08-04 review): do NOT re-derive +`Airborne` from CONTACT.** That was prepared and backed out here because it +perturbs all five `Airborne = !Body.OnWalkable` writers and contradicts a +pinned assertion in +`RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_RestoresSourceWalkabilityForFirstAcceleration` +(`InContact: true, OnWalkable: false` → `Assert.True(remote.Airborne)`). The +right change is smaller: point the **two routing gates** +(`ApplyRemoteContactRouting`'s `if (remote.Airborne)` and `OnPosition`'s +player-remote `if (rmState.Airborne)`) at `remote.Body.InContact` directly and +leave the `Airborne` flag alone. That is the literal retail predicate at the +one place the predicate is used, and it touches no existing test. + +**AP-87's 4 m snap is deliberately untouched.** It is the #184 +invisible-but-solid backstop; this change removes the CAUSE of the divergence +that made it fire, and the expected consequence is that it fires far less often. +`InterpolationManager`'s `node_fail_counter > 3` stall snap is likewise +untouched — the capture confirmed `producer=ap87-4m`, so the stall snap was +never the producer here, and it is a faithful port of +`InterpolationManager::UseTime` @0x00555f20. + +Register: **AP-81** narrowed (its whole GRAVITY half retired), **AP-87** +annotated, **AP-139** filed for the interpolation-queue clear the deleted +landing block used to own, **AP-140** filed at the review for the +walkability-vs-CONTACT routing predicate above. Coverage: +`tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteepContactSlideTests.cs` +(10 tests over a synthetic constant-gradient ramp, each individually +discriminated against a reverted fix). + +**Recorded gaps from the 2026-08-04 Opus review — the fix PASSED; these are +known, deliberately unfixed, and none of them was changed in the tightening +pass that recorded them.** + +- **The `LeaveGround` dispatch is new and unbounded.** The + `previousOnWalkable && !finalOnWalkable` arm calls + `MotionInterpreter.LeaveGround()`, which is a per-remote dispatch acdream + never made before. It is retail-shaped (`CMotionInterp::LeaveGround` + @0x00528B00 — creature gate @0x00528B36, Gravity-state gate, then the + velocity install @0x00528B66), but note what it DOES: `GetLeaveGroundVelocity` + (@0x005280c0) **replaces** the body's velocity with `get_state_velocity()` + plus a jump Z, then `RemoveLinkAnimations` + `apply_current_movement` + re-dispatches motion. Nothing bounds how often it can fire: the suite bounds + the HitGround edge at exactly 1 + (`WalkableLandingStillLandsAndFiresTheGroundEdgeOnce`) but has no + counterpart for LeaveGround, and noisy geometry — a + walkable lip alternating with a steep face across the sweep — could chatter + the edge and re-dispatch motion every tick. Compare the #270 lesson in + `claude-memory/project_physics_collision_digest.md`: a per-stats-refresh + `ReportExhaustion` re-dispatch produced 490 spurious stance re-queues in one + session; **never re-add a per-tick re-apply.** A LeaveGround-count bound is + the missing test. +- **The primary watch item for the visual gate: action animations on remotes + that fail to establish contact.** The deleted per-tick force was, in + practice, a blanket guarantee that every non-airborne remote carried + `Contact | OnWalkable`. `contact_allows_move` (@0x00528dd0) **silently + refuses action animations** for a body lacking both — that is the exact root + cause of closed issue **#270** ("monster attacks but the animation never + fires", "stuck in cast pose"). Post-fix, any remote whose sweep fails to + establish contact loses its attack/cast animations. On a 52.4-degree roof + that is retail-correct and is the point of the change. Anywhere else it is + the `feedback_latent_bug_masked_by_fallback` shape: the forge was masking + contact failures, and removing it exposes every one of them. **Watch for + missing attack/cast animations on ordinary flat ground during the two-client + gate; if any appear, the bug is in contact establishment, not in this fix.** +- **A remote whose transition keeps failing never re-derives `Airborne`.** The + whole SetPositionInternal commit is gated on `Ok && candidateMoved`, and + `rm.Airborne = !rm.Body.OnWalkable` is assigned only inside it, while the + packet-side landing block no longer clears `Airborne` either. A remote whose + transition keeps returning `!Ok` — the #116/#182 wedge class — therefore + stays flagged airborne indefinitely: it keeps integrating gravity, and every + grounded UP hard-snaps it back, producing sink-and-snap jitter at UP cadence. + Bounded (each snap is to the authoritative position) and its reachability is + unproven, but it is new with this change and did not exist while the forge + ran. + +Still open on this row: the two dependencies named above. **#173**'s remote +collision-velocity reflect now genuinely runs on a steep contact (the old code +passed `IsOnGround` as `nowOnWalkable`, which suppressed the reflect exactly +where retail forces it), but its visual gate is still unrun. **AD-10**'s +terrain-only slope projection still cannot see building geometry; it is now +correctly gated OFF while the body is not on walkable ground, so a roof slide +is driven by gravity plus the sweep's own plane projection rather than by that +approximation. The retail-strict `step_up_slide`/`cliff_slide` audit that this +row was originally filed for is unchanged. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b9859318..67a73084 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -159,7 +159,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 96 active rows (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 and NARROWED 2026-08-04 at the C4 route 4b-2 delta review — 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) — 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 and NARROWED 2026-08-04 at the C4 route 4b-2 delta review — 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 @@ -232,14 +232,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification | | AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner | | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | -| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (VectorUpdate jump handler); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (landing blocks) | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. | +| AP-81 | **NARROWED 2026-08-04 (Bug B). The remote VectorUpdate handler still pre-clears the two ground transients and seeds the client Airborne flag one frame ahead of the sweep.** The GRAVITY half of this row is RETIRED: the handler no longer writes `Body.State |= Gravity`, and neither landing block clears it, so GRAVITY_PS is wire-owned for the object's whole life exactly as retail has it (`CPhysicsObj` constructor state `0x400C08` @0x00512508; `set_state` @0x00514DD0 post-processes only lighting/nodraw/hidden and never masks GRAVITY). The per-tick force this row's sibling sites used to apply is also gone — see the Bug B entry in `docs/ISSUES.md` #32. What remains is the handler's `TransientState &= ~(Contact | OnWalkable)` plus `rm.Airborne = true` on a `Velocity.Z > 0.5f` vector. Retail reaches the identical state one frame later: `check_contact` (0x0050F5B0) fails on the ascending velocity, the transition runs contact-free, `SetPositionInternal` clears CONTACT_TS and `set_on_walkable(0)` fires LeaveGround. The pre-clear is deliberately KEPT because it is what makes the per-tick `set_on_walkable` edge observe `previousOnWalkable == false` and therefore NOT fire a second LeaveGround for the same departure, and because `CMotionInterp::LeaveGround` (0x00528B00) writes `set_local_velocity(GetLeaveGroundVelocity(), autonomous)` — relocating it into the tick would overwrite the authoritative launch vector mid-arc | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyOrdinaryVector`, the `Velocity.Z > 0.5f` branch) | One frame of head start on a state the sweep derives anyway. Both landing blocks now derive Contact/OnWalkable from the committed contact plane and neither touches the Gravity state bit, so the flag dance no longer decides whether gravity is delivered | A VectorUpdate whose vertical component clears the 0.5 m/s threshold on a body the sweep would still find in contact marks that body airborne one frame early. Retire when the remote departure edge is owned solely by the per-tick `set_on_walkable` commit and LeaveGround's velocity write is ordered after the authoritative vector | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::check_contact @ 0x0050F5B0`; `CPhysicsObj::calc_acceleration @ 0x00510950`; `CPhysicsObj::SetPositionInternal @ 0x00515330`; `set_on_walkable @ 0x00511310`; `CMotionInterp::LeaveGround @ 0x00528B00` | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | | AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` back to `WorldRenderFrameBuilder` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.ObserveDrawableCells`, `ClearDrawableCells`, `Prepare`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | 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**: the route 4a two-client test caught exactly this risk — a player remote jumping onto a house roof plants there (the landing block's unconditional `OnWalkable` forces the body to treat the steep roof as walkable, so it never slides) and sits until it has drifted >4 m from the server's actual slid-down position, at which point this backstop fires and blips it to that position instead of sliding smoothly (Bug B, `docs/ISSUES.md` #32) | `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 | `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` | @@ -285,6 +285,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review).** Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore covers the plain unplaceable-destination park and — **narrowed 2026-08-04 at the C4 route 4b-2 delta review, then relocated at that slice's round 3** — `SubmitPreparedPlacementCore`'s two collision-prefix-QUIESCENCE parks. **Corrected round 4 (D1): NOT "unconditionally" for any of the three.** Since the relocation, the same post-snap quiescence test gates EVERY park including the plain one, which the row's own next sentences already described; the word contradicted them. The original blanket "no quiescence park is restorable" was over-broad: its stated reason — re-admitting a spatial root into a retiring prefix blocks the retirement — is exact for `ParkCollisionResidents`, where the entity's OWN cell is retiring, but `TryGetBlockingQuiescence` also fires on prefixes the placement merely TOUCHES (any `QueriedCellIds` entry, i.e. a NEIGHBOUR landblock the sweep crossed a seam into; and the request's `CurrentCellId`, which on a FIRST submit names the destination rather than the departed source because both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting — scoped at round 4 (D5): a retained retry re-submits with no fresh merge, and `RemoteTeleportController`'s rollback can rebucket that field to the pre-teleport landblock, so the arm is live). The decision is taken inside `ParkDeferred`, AFTER `SnapToCell`, as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` — the cell `RestoreParkWithdrawal` will actually restore residency into, tested against EVERY live quiescence rather than against the single minimum-`OperationId` token `TryGetBlockingQuiescence` happened to return, and read after `LandDefs.AdjustToOutside` may have moved it (the reachable half of that, and the one a test now pins, is the re-derived cell: a wire (cell, position) pair whose position lies past its own named block's seam is exactly the pair #107's re-derivation distrusts, and it lands residency in a NEIGHBOUR landblock — see `QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell`). A cell id of 0 is `AdjustToOutside`'s map-edge failure sentinel, never landblock (0,0), so the map is not consulted with it (C3c-F3). **Round 4 (D6) added the same test at RESTORE time**, in `RestoreParkWithdrawal`'s residency arm: the park-time answer is a snapshot, and route 2's park is RETAINED until the next packet's merge-time `Forget` ~150 ms later, so a prefix clean when the park was taken can be quiescing when the rollback runs. The `InWorld`/transient/clock half is still restored unconditionally — it is per-entity simulation state, not a claim on any landblock's collision generation. So the rollback re-admits nothing into ANY quiescing prefix, at the moment it actually writes residency rather than only as of when the park was taken, while leaving these parks non-restorable stranded the entity `InWorld = false` / clock suspended / not a spatial root with the only operation able to wake it destroyed by its own next accepted Position. A retirement park (`ParkCollisionResidents`) is still never restored, and now says so explicitly rather than relying on a parameter default | A remote — or the LOCAL PLAYER, which traverses the same shared core through route 2 — that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 — **rewritten by this slice, not merely extended (round-4 D2 correction: this summary used to describe only the original three remote steps plus "route 2's corrections are unchanged", which no longer matches the issue's own body)**. #309 is now six steps run with `ACDREAM_PROBE_PARK=1`: the three original remote-park steps, plus a quiescing swept-NEIGHBOUR step and a quiescing-DESTINATION step that both exercise the LOCAL PLAYER through route 2 and both carry a stated `[park]`/`[park-restore]` confirmation signal (a quiescence window cannot be synchronised by hand, so without one the step passes while broken), plus the unchanged-ordinary-correction step. Step 5 also asks the tester to confirm the destination landblock's retirement still COMPLETES, and states correctly that the deliberately non-restored park does NOT recover on the next ordinary Position | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | | 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/docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md b/docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md new file mode 100644 index 00000000..d28baf7d --- /dev/null +++ b/docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md @@ -0,0 +1,559 @@ +# 2026-08-04 — Bug A / H3 diagnosis: why a remote's detected landing edge does not advance its animation + +**Status:** REPORT-ONLY. No source or test edits. Companion to +`docs/research/2026-08-04-remote-landing-investigation.md` (hypotheses + decision +table) and `docs/ISSUES.md` #32. + +**Capture that drives this report:** `launch-4b2.log:809-827`, six +`[remote-landing]` lines for guid `0x5000000F` between `t=85680843` and +`t=85748328` (67.5 s), all identical: +`airborneBefore=True gravitySet=True contact=True onWalkable=True +hasDefaultSink=True resolveIsOnGround=True seqStyle=0x8000003D +seqMotion=0x40000015`. Four `site=per-tick`, two `site=controller`. +`ACDREAM_DUMP_MOTION` was NOT enabled for this run, so there are no +`VU`/`VU.land`/`UM`/`SetCycle` lines to correlate against. + +--- + +## Headline + +**Retail leaves the Falling cycle on landing as a purely LOCAL consequence of +the observer's own physics.** The driver is the false→true edge of the +`ON_WALKABLE_TS` bit inside `CPhysicsObj::set_on_walkable` +(`named symbol @0x00511310`, pseudo-C :279287), which is reached +unconditionally from `CPhysicsObj::SetPositionInternal` +(`@0x00515330`, :283399) on every completed transition, for every object — +there is no `IsThePlayer` / `IsCreature` / ownership gate anywhere on that path. + +**acdream has a verbatim port of that edge** +(`src/AcDream.Core/Physics/PhysicsObjUpdate.cs:137-142`) and wires it for the +local player, for ordinary bodies, for placements, for spawn settle, and even +for *hidden* remotes. **The one path that does not use it is the visible +remote per-tick tick** — `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:471` +calls `HandleAllCollisions` alone, skipping the whole `SetPositionInternal` +contact/on_walkable prefix that owns the HitGround/LeaveGround edge, and +substitutes a hand-rolled, **wire-latched** landing heuristic at +`:493-580`. + +That substitution is the defect. Its two measurable consequences are named in +§2 and §5. This is the same site and the same root as Bug B (ISSUES #32's +2026-08-04 addendum): the remote's `OnWalkable` bit is *asserted*, never +*derived*. + +--- + +## 1. Where a remote's animation sequence is advanced — every writer + +`seqMotion` is `AnimationSequencer.CurrentMotion`, a read-only mirror of +`MotionState.Substate` (`src/AcDream.Core/Physics/AnimationSequencer.cs:126`; +style at `:117`). `MotionState.Substate` is written in exactly two places: +`src/AcDream.Core/Physics/Motion/CMotionTable.cs:323` (Branch 1, style change) +and `:414` (Branch 2, cycle change), both inside `GetObjectSequence` +(`:255-516`), plus the baseline install at `:622` (`SetDefaultState`). + +Every route into `GetObjectSequence` for a live entity: + +| # | Entry point | Reachable for a PLAYER remote? | +|---|---|---| +| W1 | `MotionTableDispatchSink.ApplyMotion/StopMotion/StopCompletely` (`src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs:34-57`) → `AnimationSequencer.PerformMovement` (`:473-477`) → `MotionTableManager.PerformMovement` (`src/AcDream.Core/Physics/Motion/MotionTableManager.cs:409-444`) | **Yes — the only live route.** | +| W2 | `AnimationSequencer.SetCycle` (`:353-413`) from `RemoteServerControlledVelocityCycle.cs:78` | **No** — gated `!IsPlayerGuid(serverGuid)` at `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:181`. NPC/monster only. | +| W3 | `AnimationSequencer.SetCycle` from `src/AcDream.App/Rendering/SpawnMotionInitializer.cs:34,60` | Spawn only. | +| W4 | `AnimationSequencer.SetCycle`/`PlayAction` from `src/AcDream.Core/Physics/AnimationCommandRouter.cs:78,81` | Command/emote routing, not locomotion. | +| W5 | `MotionTableManager.InitializeState` (`:353-362`) / `Reset` (`AnimationSequencer.cs:598-609`) | Lifecycle only. | + +Note: the `SetCycle` block advertised at +`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:413-463` is +**dead for the cycle** — `fullMotion` computed there is immediately overwritten +by the funnel dispatcher's result at `:602` and used only for the +locomotion-timestamp bookkeeping at `:630-638`. The comment block is stale; do +not read it as a second cycle writer. + +**So for a player remote there is exactly ONE writer, W1, and exactly two +things drive it:** + +- **(a) the inbound wire funnel** — `RemoteInboundMotionDispatcher.Apply` + (`src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs:112`) → + `MotionInterpreter.MoveToInterpretedState` + (`src/AcDream.Core/Physics/MotionInterpreter.cs:2741-2788`) → + `ApplyInterpretedMovement` (`:2764`). +- **(b) the local ground edges** — `MotionInterpreter.LeaveGround` (`:2374-2395`) + and `HitGround` (`:2426-2444`), each ending in + `apply_current_movement` (`:1460-1473`) → + `ApplyCurrentMovementInterpreted` (`:1547-1563`) → + the **same** `ApplyInterpretedMovement` (`:2842-2903`) through the **same** + `DefaultSink`. + +**Answer to "can anything other than an inbound `UpdateMotion` move a remote +out of Falling?" — Yes, exactly one thing: `HitGround` (b).** There is no +timer, no per-frame recompute, and no NPC-style velocity-cycle fallback for a +player remote. If (b) is a no-op, the wire is the only escape — which is +precisely the reported symptom. + +--- + +## 2. Where the landing edge is detected, and what each site does with it + +Two sites, both real, neither of which silently drops the edge: + +**Site A — `site=per-tick`**, `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:493-580` +``` +:493 if (rm.Airborne && resolveResult.IsOnGround && rm.Body.Velocity.Z <= 0f) +:497 rm.Airborne = false; +:502 rm.Interp.Clear(); +:503-504 rm.Body.TransientState |= Contact | OnWalkable; // ASSERTED, not derived +:505-506 rm.Body.Velocity = (X, Y, 0) +:538-557 [remote-landing] probe +:559 rm.Movement.HitGround(); +:574-576 rm.Body.State &= ~Gravity; +``` + +**Site B — `site=controller`**, `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2019-2117` +``` +:2019 if (rmState.Airborne) +:2021 rmState.Airborne = false; +:2023-24 rmState.Body.TransientState |= Contact | OnWalkable; // ASSERTED, not derived +:2065-69 EnsureRemoteMotionBindings (creates the sink if missing) +:2077-96 [remote-landing] probe +:2100 rmState.Movement.HitGround(); +:2114-15 rmState.Body.State &= ~Gravity; +``` + +Both reach `MovementManager.HitGround` +(`src/AcDream.Core/Physics/Motion/MovementManager.cs:153-156`) → +`MotionInterpreter.HitGround`. **The edge is not dropped at either site.** Under +the captured probe values every gate in `HitGround` passes: +`PhysicsObj` non-null (`MotionInterpreter.cs:2428`), `IsCreature` true +(`RemoteWeenie` inherits the `IWeenieObject.IsCreature() => true` default at +`MotionInterpreter.cs:462`), Gravity set (`:2435`, probe `gravitySet=True`), +`Initted` true by default (`:747`). `apply_current_movement`'s dual dispatch +(`:1465-1470`) routes a remote to the INTERPRETED branch because +`RemoteWeenie.IsThePlayer()` is the `false` default (`:475`), and +`ApplyCurrentMovementInterpreted` takes the sink branch (`:1558-1563`) because +`DefaultSink` is bound (probe `hasDefaultSink=True`). + +**The real defect is not that the edge is dropped — it is that the edge is +INVENTED.** `rm.Airborne` is an acdream latch, not retail state. It is set at +exactly one place: + +``` +src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:1265-1273 + if (update.Velocity.Z > 0.5f) { // 0xF74E VectorUpdate only + rm.Airborne = true; + rm.Body.TransientState &= ~(Contact | OnWalkable); + rm.Body.State |= PhysicsStateFlags.Gravity; + ... +:1290 rm.Motion.LeaveGround(); + } +``` +(the other setter, `RemoteTeleportController.cs:399`, is the teleport path). + +So for a visible remote, **both** ground edges and **both** the Gravity bit and +the contact bits are driven by one wire packet with a `0.5 m/s` magic +threshold, rather than by the resolved contact plane. Retail's contact bits are +derived from `contact_plane.N.z >= floor_z` on every transition +(`@0x00515330`, :283501-283509) and `GRAVITY_PS (0x400)` is a persistent object +property, never a per-jump latch. + +Two direct consequences, both citable: + +- **C1 — the edge cannot fire at all for an airborne episode that does not + begin with a `+Z > 0.5` VectorUpdate** (walk off a ledge, step off a porch, + a dropped/reordered `0xF74E`). In that case `LeaveGround` also never runs, so + Falling would instead have to be engaged by the wire funnel's own airborne + substitution (`MotionInterpreter.cs:2864-2868`) — which requires Gravity set, + which also never happened. The remote then falls with its grounded cycle + playing. +- **C2 — Gravity is cleared immediately after the first landing edge** + (`RuntimeRemotePhysicsUpdater.cs:574-576`, + `LiveEntityNetworkUpdateController.cs:2114-2115`), whereas the local player's + body is constructed with Gravity (`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:689`) + and never clears it. With Gravity clear, `contact_allows_move` returns `true` + early (`MotionInterpreter.cs:2142-2143`) and `HitGround` no-ops outright + (`:2435`) — so a *second* airborne episode before the next VectorUpdate has no + animation response at all in either direction. + +Neither C1 nor C2 explains the six captured edges (all had `gravitySet=True`), +but both are the same root cause and both must be fixed by the same change. + +--- + +## 3. What retail does — LOCAL, unconditional, contact-driven + +Sourced from `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +**3.1 `CPhysicsObj::set_on_walkable` — `@0x00511310`, :279287** is the driver: +``` +00511336 if ((transient_state & 2) == 0) // OLD value not walkable +00511358 if (arg2 != 0) // -> false->true EDGE +0051135a movement_manager_1 = this->movement_manager; +00511364 MovementManager::HitGround(movement_manager_1); +00511336 else if (arg2 == 0) // true->false edge +00511346 MovementManager::LeaveGround(movement_manager); +``` +The only condition is `movement_manager != 0`. **No ownership, creature, or +player gate.** (`ON_WALKABLE_TS = 0x2`, `docs/research/named-retail/acclient.h:3691`.) + +**3.2 `CPhysicsObj::SetPositionInternal` — `@0x00515330`, :283399** calls it, +unconditionally, from the resolved contact plane: +``` +00515430 if (collision_info.contact_plane_valid == 0) ts &= ~1; else ts |= 1; // CONTACT_TS +00515465 if ((ts & 1) == 0) { ts &= ~2; MovementManager::LeaveGround(...); } +0051548e else if (contact_plane.N.z < PhysicsGlobals::floor_z) set_on_walkable(this, 0); // :283507 +00515482 else set_on_walkable(this, 1); // :283509 +005154fe CPhysicsObj::handle_all_collisions(this, &collision_info, ts&1, ts&2); +``` +`handle_all_collisions` (`@0x00514780`, :282647) does **not** touch +`on_walkable` — it only does the elasticity reflect and the +`frames_stationary_fall` bookkeeping. **The contact/walkable recompute lives +exclusively in `SetPositionInternal`, and it runs BEFORE +`handle_all_collisions`.** + +Wire-driven placements reach the same code: +`CPhysicsObj::SetPositionInternal(Position*, SetPositionStruct*, CTransition*)` +(`@0x00515bd0`, :283892) calls the transition form at `@0x00515c94` (:283942). + +**3.3 Retail runs full physics for objects it does not own.** +`CPhysics::UseTime` (`@0x00509950`, :271586) iterates the whole object hash and +calls `CPhysicsObj::update_object` on every entry (:271643); the only +player-specific line is an extra +`SmartBox::PlayerPhysicsUpdatedCallback` notification, not a skip. +`update_object` (`@0x00515d10`, :283950) early-outs only on +`parent != 0 || cell == 0 || (state & 0x1000000)` — nothing about ownership. +`UpdateObjectInternal` (`@0x005156b0`, :283611) → +`UpdatePositionInternal` (`@0x00512c30`, :280817) → +`CPhysicsObj::transition` (`@0x005158b2`) → `SetPositionInternal` (`@0x00515914`). +**So on a retail observer, a remote player's landing animation exit is a local +physics consequence, not a wire event.** + +**3.4 The re-apply.** `MovementManager::HitGround` (`@0x00524300`, :300425) → +`CMotionInterp::HitGround` (`@0x00528ac0`, :305996 — creature gate, +`state & 0x400` gravity gate, `RemoveLinkAnimations`, +`apply_current_movement(0,0)`) → `apply_current_movement` (`@0x00528870`, +:305838; its `IsThePlayer` test is a *source selector* — raw vs interpreted — +not a skip) → `apply_interpreted_movement` (`@0x00528600`, :305713): +``` +0052866c if (contact_allows_move(this, interpreted_state.forward_command) == 0) +005286ee DoInterpretedMotion(this, 0x40000015, &var_2c); // :305729 FALLING +0052866c else +00528687 DoInterpretedMotion(this, interpreted_state.forward_command, ...); // :305744 +``` +`contact_allows_move` (`@0x00528240`, :305471) returns 1 iff +`CONTACT_TS && ON_WALKABLE_TS` (given a gravity-bound creature). **`0x40000015` +is dispatched from exactly one site in the entire 1.4 M-line binary** — that +one line. Entry to and exit from Falling are both decided by the contact bits. + +**3.5 There is no other landing path.** `symbols.json` contains no +`land`/`land_on_ground` animation function (only `CSphere::land_on_sphere` +`@0x005379A0` and `CCylSphere::land_on_cylinder` `@0x0053B3D0`, both collision +geometry). The complete caller set of `apply_current_movement` is `HitGround` +(`@0x00528af7`), `LeaveGround` (`@0x00528b66`), `set_hold_run` (`@0x00528b9e`), +`SetHoldKey` (`@0x00528bd4`/`@0x00528bef`), `ReportExhaustion` (`@0x005288ed`), +`SetWeenieObject` (`@0x00528955`), and `@0x00528a08`. Landing is `HitGround` +and nothing else. + +**3.6 The retail default that matters:** `InterpretedMotionState::InterpretedMotionState` +(`@0x0051e8d0`, :293418) sets `forward_command = 0x41000003` (Ready). A remote +that never received an mt-0 `UpdateMotion` still re-applies **Ready** on +landing, never Falling. acdream matches this +(`src/AcDream.Core/Physics/MotionInterpreter.cs:215-224`). + +--- + +## 4. Does the local player differ? Yes — and that is the shape of the fix + +**The local player implements retail's contact-derived edge inline.** +`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:2607-2645`: +``` +:2608 if (resolveResult.Ok && candidateMoved) { +:2610-13 Contact <- resolveResult.InContact +:2616 if (resolveResult.InContact && resolveResult.OnWalkable) { +:2618 bool wasAirborne = !_body.OnWalkable; // read BEFORE the write +:2619 _body.TransientState |= OnWalkable; +:2620-27 if (wasAirborne) { Movement.HitGround(); landedThisQuantum = true; } +:2630-33 } else { _body.TransientState &= ~OnWalkable; } +:2641-44 PhysicsObjUpdate.HandleAllCollisions(...); // AFTER the edge, as retail +:2650-51 if (!_body.OnWalkable && !_wasAirborneLastFrame) _motion.LeaveGround(); +``` +That is `set_on_walkable`'s edge, derived from the resolved contact plane, +with `handle_all_collisions` in retail's order. + +**Four other acdream paths already use the extracted seam:** + +| Path | Call | +|---|---| +| Local player, hidden/PositionManager | `PlayerMovementController.cs:2044-2053` → `CommitSetPositionTransition(..., Movement.HitGround, _motion.LeaveGround)` | +| Ordinary live bodies | `src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs:168` | +| Canonical placements (incl. remotes) | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs:4912-4919` → `..., remote.HitGround, remote.LeaveGround, guard.IsCurrent` | +| **Hidden remotes** | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:778-796` → `CommitSetPositionTransition(..., rm.Movement.HitGround, rm.Motion.LeaveGround, ...)`, then `:796 rm.Airborne = !rm.Body.OnWalkable;` | +| Spawn settle | `src/AcDream.Core/Physics/SpawnPlacementSettler.cs:62` | + +The seam is `src/AcDream.Core/Physics/PhysicsObjUpdate.cs:120-151`: +``` +:131 bool finalOnWalkable = CommitSetPositionContactPrefix(body, inContact, onWalkable, previousOnWalkable); +:137 if (!previousOnWalkable && finalOnWalkable) hitGround?.Invoke(); +:143 else if (previousOnWalkable && !finalOnWalkable) leaveGround?.Invoke(); +``` +with `finalOnWalkable = inContact && onWalkable` at `:169` and +`IsWalkableContact(inContact, n) => inContact && n.Z >= PhysicsGlobals.FloorZ` +at `:20-21` — a verbatim port of :283501-283509. + +**The VISIBLE remote per-tick path is the only one that bypasses it.** +`RuntimeRemotePhysicsUpdater.cs:471-477` calls `PhysicsObjUpdate.HandleAllCollisions` +directly — the *tail* of `SetPositionInternal` without its *prefix* — so the +remote's `Contact`/`OnWalkable` bits are never derived from the resolve, the +false→true edge never exists, and `HitGround`/`LeaveGround` have no driver. +The `rm.Airborne` latch at `:493` is the stand-in. + +**So yes: the same mechanism is simply not wired for visible remotes, and it +is already wired 300 lines below in the same file for hidden ones.** The fix is +small. + +--- + +## 5. Proposed fix + +### 5.1 The change (H3's file, per the investigation doc's three-file split) + +The investigation doc says H1's fix is in the Gravity-clear sites, H2's is in +the sink-binding race, and **H3's is "the animation-scheduler consumption +path, not physics."** That framing needs one correction, which this trace +establishes: the consumption path is fine (see §5.3) — H3's actual file is +**`src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`**, the *producer* +of the animation command, not `LiveEntityAnimationScheduler`/`Presenter`. + +**Target: `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:471-580`.** + +Replace the direct `HandleAllCollisions` call at `:471-477` **and** the entire +hand-rolled landing block at `:493-580` with the same call the hidden path +already makes at `:778-796`: + +```csharp +// retail CPhysicsObj::SetPositionInternal @0x00515330 (:283501-283509) -> +// set_on_walkable @0x00511310 (:279287): contact/on_walkable derived from the +// contact plane, HitGround/LeaveGround on the false<->true edge, then +// handle_all_collisions @0x00514780. Same order, same seam as the hidden +// remote path below and the local player at PlayerMovementController:2607. +if (!PhysicsObjUpdate.CommitSetPositionTransition( + rm.Body, + resolveResult.InContact, + resolveResult.OnWalkable, // DERIVED, not asserted + resolveResult.CollisionNormalValid, + resolveResult.CollisionNormal, + previousContact, + previousOnWalkable, + rm.Movement.HitGround, + rm.Motion.LeaveGround, + () => IsCurrentOwner(record, rm, objectClockEpoch, externalOwnerValid))) +{ + return false; +} +rm.Airborne = !rm.Body.OnWalkable; // derived state, not a wire latch +``` + +Three supporting deletions/changes fall out of it, all required for the seam to +be the single owner: + +1. **Delete the unconditional `TransientState |= Contact | OnWalkable`** at + `:152-154` (the `!rm.Airborne` per-tick force) — it is what makes the edge + structurally impossible. This is also Bug B's forced-`OnWalkable` + (ISSUES #32 addendum), so the two bugs close together. +2. **Delete the twin landing block** at + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2019-2117`'s + `TransientState |= Contact | OnWalkable` + `HitGround` + Gravity-clear, and + the `rm.Airborne = true` + contact-clear + Gravity-set at `:1265-1273`. + `LeaveGround` at `:1290` goes with them — retail fires it from + `set_on_walkable`, not from a `0xF74E` handler. The VectorUpdate keeps only + what retail's `PhysicsDesc` write does: install the wire velocity. +3. **Make Gravity persistent on remote bodies**, matching + `PlayerMovementController.cs:689`: construct `RemoteMotion.Body` with + `PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions` + (`src/AcDream.Runtime/Physics/RemoteMotion.cs:278`) and delete both + Gravity-clear sites (`RuntimeRemotePhysicsUpdater.cs:574-576`, + `LiveEntityNetworkUpdateController.cs:2114-2115`). Retail's `GRAVITY_PS` + (`0x400`) is an object property, and both `HitGround` (`:2435`) and + `contact_allows_move` (`:2142`) read it as one. Without this, C2 (§2) + survives the fix. + +This is *not* a symptom-site guard: it deletes the invented mechanism and +installs the retail one that the rest of the codebase already uses. + +### 5.2 Blast radius + +- **Player remotes** — the intended target. Landing/leaving-ground animation + becomes local and unconditional, as retail. Also removes the `0.5 m/s` + magic threshold and the wire dependency for the whole ground-edge family. +- **NPCs / monsters** — same code path (`#184` Slice 2b unified them; there is + no player/NPC fork in `Tick`). They gain a correct `LeaveGround` when they + walk off a ledge, which they do not have today. Their separate + stale-velocity cycle stop (`RuntimeRemotePhysicsUpdater.cs:181-192` → + `RemoteServerControlledVelocityCycle.cs:78`) is untouched. +- **Local player** — untouched. It already does this inline + (`PlayerMovementController.cs:2607-2645`). +- **Projectiles** — untouched. They use + `ProjectilePhysicsStepper.cs:374` (`ApplySetPositionContact`) and are + excluded from this branch by `projectileHandlesMovement` + (`LiveEntityAnimationScheduler.cs:336`). +- **Hidden remotes / placements / spawn settle** — untouched; they already + call the seam. +- **Bug B** — expected to change behavior at the same commit, because a steep + roof stops being force-marked `OnWalkable`. §5.5 covers the coupling. +- **Register bookkeeping** — this retires the "forced Contact|OnWalkable on + remote landing" deviation and the `rm.Airborne` wire-latch deviation, and it + touches AD-25's call-site note at `RuntimeRemotePhysicsUpdater.cs:449-470` + (the comment claims the direct `HandleAllCollisions` call matches "retail's + own unconditional call site" — it does not, because retail's call site is + *inside* `SetPositionInternal`, after `set_on_walkable`). Per CLAUDE.md the + register rows must be updated in the same commit. + +### 5.3 What this fix does NOT do — and why the scheduler is exonerated + +The animation-scheduler consumption path was H3's literal wording. It is +clean: + +- `LiveEntityAnimationScheduler.cs:323-326` advances the sequencer and captures + the pose, **then** `:351-364` runs `_remotePhysics.Tick` (where `HitGround` + fires). So a cycle change lands in the *next* quantum's pose — a one-frame + lag, not a multi-second one. +- `LiveEntityMotionRuntimeController.cs:39-47` creates `rm.Sink` once over + `ae.Sequencer` and binds it as `rm.Motion.DefaultSink` (`:46`). The **same** + cached sink is handed to the inbound funnel at + `LiveEntityNetworkUpdateController.cs:756,766`. A stale-sequencer theory is + therefore refuted by observation: if the sink pointed at a dead sequencer, + the wire path could not clear the pose either — and the user reports it does. +- `RemoveLinkAnimations` is bound to `sequencer.Manager.HandleEnterWorld` + (`LiveEntityMotionRuntimeController.cs:63`), which is the correct `#174` + binding. + +### 5.4 Second, independent hazard found on the way — flag, do not fix blind + +`MovementParameters.ModifyInterpretedState` defaults to **true** +(`src/AcDream.Core/Physics/Motion/MovementParameters.cs:140`), and +`MoveToInterpretedState`'s action-replay loop +(`MotionInterpreter.cs:2766-2784`) dispatches with ctor-default params +(`DispatchInterpretedMotion`, `:3222-3225`). `InterpretedMotionState.ApplyMotion` +(`:285-313`) writes `ForwardCommand = motion` for **any** id carrying +`0x40000000` (`:299-304`) — which includes `Falling` (`0x40000015`). +`InboundInterpretedMotionFactory.Create` +(`src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs:46-63`) restores +the real class byte via `MotionCommandResolver.ReconstructFullCommand`, whose +catalog is built from the DatReaderWriter `MotionCommand` enum — so a wire +`Commands[]` entry of `0x0015` would resolve to `0x40000015`. + +If that ever happens, `InterpretedState.ForwardCommand` becomes `Falling`, and +`HitGround`'s re-apply at `MotionInterpreter.cs:2878` re-dispatches Falling — +producing **exactly** "stuck in Falling until the next motion update +overwrites ForwardCommand." This mechanism would survive the §5.1 fix. + +**NOT ESTABLISHED:** whether ACE ever puts a `0x4x`-class command in +`Commands[]` for a jumping/falling player. Retail's own outbound cannot +(its `ModifyInterpretedState=false` invariant keeps Falling out of +`interpreted_state`), but ACE is an emulator. **Settle it with one capture:** +`ACDREAM_DUMP_MOTION=1 ACDREAM_REMOTE_VEL_DIAG=1` across a remote jump, and +read the `[UM_RAW]`/`[FWD_WIRE]` lines +(`LiveEntityNetworkUpdateController.cs:373-386`, `:777-786`) for a +`ForwardCommand` or `Commands[]` entry of `0x0015` during the airborne window. + +### 5.5 Sequencing note + +Bug B's addendum (ISSUES #32) records that correcting `OnWalkable` alone may +not reproduce retail's roof slide, because it also depends on **#173**'s remote +collision-velocity reflect (shipped, gate unrun) and **AD-10**'s terrain-only +slope projection. That does not block this fix — the animation edge is +independent of the slide response — but expect the roof case to change +appearance at the same commit, and gate both together. + +--- + +## 6. How to test + +### 6.1 The measurement that is still missing + +**The existing probe reads the wrong side of the call.** It captures state +immediately *before* `HitGround` (`RuntimeRemotePhysicsUpdater.cs:538-557`, +`LiveEntityNetworkUpdateController.cs:2077-2096`), and the investigation doc +already flagged for H3 that "the read happens immediately before HitGround +runs, so this alone doesn't distinguish success from H3." The six captured +lines therefore prove the gates pass and prove nothing about the outcome. + +Before or alongside the fix, one line of instrumentation settles it: emit a +second `[remote-landing-after]` line immediately after `rm.Movement.HitGround()` +carrying `seqMotion`, `InterpretedState.ForwardCommand`, and the +`MotionTableManagerError` returned by the sink's `ApplyMotion`. Three outcomes, +three different conclusions: + +| post-`seqMotion` | `ForwardCommand` | Conclusion | +|---|---|---| +| Ready (`0x41000003`) | Ready | The edge works; the residual is elsewhere (re-verify the user's observation) | +| Falling (`0x40000015`) | Falling | §5.4 — the wire clobbered `ForwardCommand` | +| Falling | Ready, sink returned `0x43` | `CMotionTable.IsAllowed` (`CMotionTable.cs:172-188`) refused the Ready cycle because its `MotionData.Bitfield & 2` is set — a DAT fact, not a source fact | + +### 6.2 Automated + +New tests in `tests/AcDream.Runtime.Tests/Physics/` (the layer under test): + +1. **Edge from contact, not from the wire.** Drive `RuntimeRemotePhysicsUpdater.Tick` + with a stubbed resolve returning `InContact=false, OnWalkable=false` then + `InContact=true, OnWalkable=true`, with **no** VectorUpdate ever delivered. + Assert `HitGround` fired exactly once on the transition and `LeaveGround` + exactly once on the opposite one. This fails today (C1). +2. **Steep contact does not land.** Resolve returns `InContact=true` with a + contact normal whose `Z < PhysicsGlobals.FloorZ`. Assert `OnWalkable` stays + clear and `HitGround` did **not** fire. This is the Bug B assertion at the + same site. +3. **Idempotence.** Two consecutive grounded quanta fire `HitGround` once, not + twice. +4. **Gravity persistence.** After a landing, assert + `rm.Body.State.HasFlag(PhysicsStateFlags.Gravity)` — then run a second + airborne→grounded cycle and assert `HitGround` fires again. This fails today + (C2). +5. **End-to-end cycle exit.** With a real `MotionTableDispatchSink` over a test + `AnimationSequencer` seeded to `Substate = Falling` and + `InterpretedState.ForwardCommand = Ready`, assert `CurrentMotion` leaves + `0x40000015` after the grounded commit. This is the assertion that makes the + §6.1 table unnecessary going forward. +6. **Regression guard on the existing contract:** + `tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs:201` + (`HitGround_AfterFall_RedispatchesPreservedForward_ExitsFalling`) already + covers the interp layer with a fake sink and must stay green. + +### 6.3 What the user would see + +Two-client route 4a, retail driving `+Acdream`'s neighbour: + +- **Jump on flat ground** — the falling pose clears the instant the remote's + feet touch, with no wire round-trip and no wait for the next `UpdateMotion`. +- **Walk off a low ledge / porch step** (no jump packet at all) — the remote + now plays the falling cycle on the way down and exits it on landing. Today it + keeps its grounded cycle the whole way, because `rm.Airborne` never latches. +- **Two jumps in quick succession** — the second one animates identically to + the first (today the Gravity clear can mute it). +- **Jump onto a house roof (Bug B)** — the remote should now slide rather than + plant, subject to the #173 / AD-10 coupling in §5.5. Gate this together with + Campaign P matrix scenario 8. + +--- + +## NOT ESTABLISHED + +1. **Which of the two terminal failures actually fires at the captured edges.** + The probe measures before the call. §6.1 names the exact one-line + instrumentation and the three-way decision table. Everything upstream of the + sink call is established: all gates pass, the sink is bound, and the dispatch + target is `InterpretedState.ForwardCommand`. +2. **Whether ACE emits a `0x4x`-class motion command for a falling player** + (§5.4). Settled by `ACDREAM_DUMP_MOTION=1` + `ACDREAM_REMOTE_VEL_DIAG=1` + across a remote jump. +3. **Whether the humanoid MotionTable's `Ready` cycle carries + `MotionData.Bitfield & 2`.** This is DAT content, not source. If set, + `CMotionTable.IsAllowed` (`:172-188`, retail `is_allowed @0x005226c0`, + :298526) refuses a Ready cycle requested while the substate is Falling, + because Falling is not any style's default substate. Observation argues + against it — the wire path uses the identical `GetObjectSequence` call and + does clear the pose — but it is not proven. Settle by dumping + `MotionData.Bitfield` for cycle key `(style << 16) | 0x000003`, or with + `bp acclient!CMotionTable::is_allowed` reading `[edx+0x30]`. +4. **Whether ACE sets `GRAVITY_PS (0x400)` in the broadcast `PhysicsDesc` for + remote creatures.** Relevant only to how faithfully §5.1's item 3 should be + implemented (construct-with-Gravity vs adopt-from-wire). cdb: + `bp acclient!CMotionInterp::contact_allows_move` dumping + `physics_obj->state`. diff --git a/docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md b/docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md new file mode 100644 index 00000000..da5e1031 --- /dev/null +++ b/docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md @@ -0,0 +1,698 @@ +# 2026-08-04 — Bug B (remote ledge/roof slide) diagnosis + +**Status:** REPORT-ONLY. No source or test edits made. HEAD `7f1c1f5a`. +**Companion:** `docs/research/2026-08-04-remote-landing-investigation.md` (Bug A), +`docs/ISSUES.md` #32 (2026-08-04 addendum), divergence register rows **AP-87**, +**AP-81**, **AD-10**. + +**Relationship to the prior same-day investigation.** ISSUES.md #32 already +carries a 2026-08-04 root-cause addendum naming the landing block's +unconditional `Contact | OnWalkable`. That addendum is **correct but +incomplete**: it names one of *four* independent links in the chain, and the +one it names is not the dominant one. Fixing only the landing block cannot +produce a slide. §1 below establishes the full chain; §6 states what that means +for the fix. Everything here is re-verified against source and against the +pseudo-C directly — nothing is carried over on trust. + +--- + +## 1. Does acdream locally simulate remote bodies, and would it slide one off a steep roof? + +**It simulates them, and it cannot slide them. Four independent links each +block the slide on their own.** + +The per-tick remote owner is +`src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`, `Tick(...)` +(`:61-650`). It runs once per eligible remote per retail object quantum and +*does* call `PhysicsEngine.ResolveWithTransition` (`:370-418`) — the same sweep +the local player uses. So the naive framing ("acdream only applies wire +positions") is **wrong**: there is a real per-tick sweep. The problem is that +everything fed into that sweep has been pre-flattened. + +### Link 1 — the grounded branch force-sets `Contact | OnWalkable` every tick, unconditionally + +`RuntimeRemotePhysicsUpdater.cs:150-154`: + +```csharp +if (!rm.Airborne) +{ + rm.Body.TransientState |= TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.Active; +``` + +The gate is `rm.Airborne` — an acdream client-side bool — **not** the contact +plane and not the wire bit. This is the dominant site: it runs *before every +sweep, on every tick*, so it re-asserts the walkable lie even if some other +site cleared it. The landing block that ISSUES.md #32 names +(`LiveEntityNetworkUpdateController.cs:2023-2024`) and its per-tick twin +(`RuntimeRemotePhysicsUpdater.cs:503-504`) fire once each per landing; this one +fires ~30 times a second forever. + +### Link 2 — a grounded remote's velocity is zeroed every tick + +`RuntimeRemotePhysicsUpdater.cs:169`: + +```csharp +rm.Body.Velocity = System.Numerics.Vector3.Zero; +``` + +There is nothing left to slide *with*. This also silently discards any +authoritative velocity ACE delivered: `OnVector` (0xF74E) writes +`update.Velocity` into the body via `TryCommitAuthoritativeVector` +(`LiveEntityNetworkUpdateController.cs:1250-1258`), but a downhill slide has +`Velocity.Z < 0`, so the `update.Velocity.Z > 0.5f` test at `:1265` leaves +`rm.Airborne == false`, and the next tick's `:169` erases the vector. + +### Link 3 — the Gravity state bit is cleared at landing and never restored + +`RuntimeRemotePhysicsUpdater.cs:571-576` and +`LiveEntityNetworkUpdateController.cs:2110-2116` both do +`rm.Body.State &= ~PhysicsStateFlags.Gravity` after `HitGround()`. +`PhysicsBody.calc_acceleration()` (`PhysicsBody.cs:503-518`) returns +`Acceleration = Vector3.Zero` when the Gravity bit is clear. The bit is only +ever *set* by the jump VectorUpdate (`:1273`) and once at body construction +(`RuntimeRemoteBodyDescription.cs:221` / `RuntimePhysicsState.InitializeNewPhysicsBody`, +`RuntimePhysicsState.cs:2617`), and that initializer runs **once per +incarnation** ("Later SetState never replays them", +`RuntimePhysicsState.cs:2608-2612`). So after the first landing the remote has +no gravity for the rest of its life. + +Retail does the opposite: GRAVITY_PS is a persistent object property and +gravity *acceleration* is gated on the CONTACT/ON_WALKABLE transients inside +`calc_acceleration`, not on the state bit being toggled. This is already filed +as register row **AP-81** — but AP-81's file:line column lists only the +VectorUpdate handler and the landing blocks, not `:150-154` (Link 1). + +### Link 4 — the visible remote tick never commits the sweep's own contact classification + +The engine *does* compute the retail answer. `PhysicsEngine.cs:2653-2676`: + +```csharp +bool inContact = ci.ContactPlaneValid; +bool onWalkable = PhysicsObjUpdate.IsWalkableContact(inContact, ci.ContactPlane.Normal); +bool onGround = inContact || (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0; +``` + +and `IsWalkableContact` (`PhysicsObjUpdate.cs:20-21`) is +`inContact && contactNormal.Z >= PhysicsGlobals.FloorZ` — retail-shaped. Both +`InContact` and `OnWalkable` are carried out on `ResolveResult` +(`ResolveResult.cs:46-53`). + +`Tick` reads **only** `resolveResult.Position`, `.CellId`, `.IsOnGround`, +`.CollisionNormalValid`, `.CollisionNormal` (`:420-477`). It never reads +`.InContact` or `.OnWalkable`, and — unlike `TickHidden`, which calls +`PhysicsObjUpdate.CommitSetPositionTransition` at `:778-795` — it never commits +them to the body. `PhysicsEngine` itself writes `ContactPlane*`, +`WaterContact`, `Sliding`, and the Stationary bits back onto the body, but +**not** `Contact`/`OnWalkable` (grep of `PhysicsEngine.cs` for +`body.TransientState`: `:2488-2540` only). + +Worse, `IsOnGround` is `inContact || …` — so a **steep-roof contact reports +`IsOnGround == true`**. That is the value the airborne→grounded landing +detection at `:493-495` tests. The remote therefore "lands" on a surface retail +would classify as contact-but-not-walkable, and the landing block then forces +`OnWalkable` and clears Gravity. + +**Conclusion for §1:** a remote resting on a steep roof is held there by +acdream's own physics, not merely parked at a wire position. The sweep runs +every tick and returns "no movement" because the mover has zero velocity, zero +acceleration, and a forged walkable contact. + +--- + +## 2. What produces the blip + +**Two candidates, both live, and they are distinguishable only by capture.** +The first is the one AP-87 predicted; the second is a retail-faithful mechanism +firing correctly on a body that has been frozen by §1 — and it fits the user's +timing description ("stay there *briefly*, then blip") better. + +### Candidate 1 — AP-87's `bodyToTarget > 4 m` snap + +`src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs:129-137`: + +```csharp +bool firstUp = remote.LastServerPosTime <= 0.0; +float bodyToTarget = Vector3.Distance(remote.Body.Position, worldPosition); +if (firstUp || !willBeDrTicked || bodyToTarget > BodySnapThreshold) // BodySnapThreshold = 4f, :42 +{ + remote.Interp.Clear(); + remote.Body.Position = worldPosition; + remote.Body.Orientation = orientation; + return Action.Snapped; +} +``` + +This requires the server position to have descended more than 4 m from the +parked body. It only fires on a packet that classifies `Interpolate`. + +### Candidate 2 — the InterpolationManager's own stall blip (`node_fail_counter > 3`) + +`src/AcDream.Core/Physics/InterpolationManager.cs:405-467` ports retail's +5-frame stall window and its snap-to-tail: + +```csharp +if (_frameCounter >= StallCheckFrameInterval) // 5 frames +{ + float cumulative = _originalDistance - dist; // progress made + … + if (!primaryPass && !secondaryPass) _failCount++; // no progress -> fail + else _failCount = 0; +} +… +if (_failCount > StallFailCountThreshold) // > 3, :458 +{ + InterpolationNode tail = _queue.Last!.Value; + Vector3 tailDelta = tail.TargetPosition - currentBodyPosition; + Clear(); + return new InterpolationStep(true, tailDelta, tail.TargetOrientation); +} +``` + +This is a faithful port of retail `InterpolationManager::UseTime` **@0x00555f20** +(pseudo-C :353261): `if (node_fail_counter > 3)` → `SetPositionSimple(physics_obj, +, 1)` @0x0055605D / @0x00556021, then +`StopInterpolating` @0x00556061. Verified directly. + +A body frozen by §1 makes **zero** progress every window, so `_failCount` +increments on every 5th frame and crosses 3 after ~20 frames — **about 0.33 s at +60 Hz**. That is "stay there briefly, then blip", and the blip lands exactly at +the tail waypoint, i.e. the server's already-slid-down position. + +The code is not wrong. Retail's own remote would never trip this, because +retail's remote is genuinely sliding and therefore genuinely making progress. +**This is a downstream symptom of §1, not an independent defect — do not touch +`InterpolationManager`.** + +### Elimination of the remaining candidates + +| Candidate | Site | Verdict | +|---|---|---| +| Far snap (≥96 m) — route 4b-2, just landed at `7f1c1f5a` | `RuntimeRemoteFarSnapPosition.ResolveArm`; classifier `RuntimeAuthoritativePositionRouteClassifier.cs:454-459` (`nearby = PlayerDistance < 96f`) | **Ruled out.** The observer is watching the house; `player_distance` is far under 96 m, so the classification is `Interpolate`, never `SetPositionSimple`. | +| `RemoteContactArm.AirborneSnap` | `LiveEntityNetworkUpdateController.cs:1031-1049` | Ruled out for the *post-plant* blip: it requires `remote.Airborne`, which is false once the body has planted (cleared at `:2021` / `:497`). It *is* the mechanism for the initial plant-onto-the-roof snap. | +| Airborne no-op branch | `LiveEntityNetworkUpdateController.cs:1994-1998` / `:2273-2280` | Not a writer at all — it writes nothing but `CellId` + `LastServerPos` (AP-135). It is a *contributor* (see below), not the blip. | +| Teleport / `ForcePosition` | `RemoteTeleportController` | Ruled out: requires a bumped teleport/force-position sequence, which ACE does not emit for ordinary sliding movement. | +| `InterpolationManager` 100 m autonomy blip | — | Ruled out at this distance. | + +### The un-established half: what the wire says during the slide + +There are **two** shapes of the same symptom, and code alone cannot tell them +apart: + +- **Shape A** — ACE reports `IsGrounded == false` throughout the retail + sender's slide. ACE derives that flag from its *own* server-side physics: + `references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs:72-73`, + `if ((PhysicsObj.TransientState & TransientStateFlags.OnWalkable) != 0) flags |= PositionFlags.IsGrounded;`. + A steep roof is not walkable, so if ACE's server physics classifies it the + same way retail does, the whole slide arrives with the bit clear. Every one of + those packets classifies `NoPositionOperation` + (`RuntimeAuthoritativePositionRouteClassifier.cs:422-443`) and acdream writes + **nothing** — not even the interpolation queue. The remote is frozen for the + entire slide, and the first `IsGrounded == true` packet after the sender + reaches walkable ground fires the 4 m snap. Visible as: *land, total + stillness, one large blip.* +- **Shape B** — ACE reports `IsGrounded == true` throughout. Each packet + classifies `Interpolate` and feeds the queue. The body still cannot move (§1), + so **Candidate 2 fires first**, at ~0.33 s, snapping to the tail waypoint; + Candidate 1 would only get its turn if the queue were empty. Visible as: + *land, ~third of a second, blip.* + +Note that in **Shape A** the queue is never fed at all (the `NoPositionOperation` +branch writes nothing), so Candidate 2 cannot fire — the blip must be +Candidate 1, on the first grounded packet after the sender reaches walkable +ground. **The two shapes therefore have different blip producers**, which is +precisely why the capture is worth taking. This is **NOT ESTABLISHED**. + +One further §1 consequence worth naming here: retail's +`InterpolationManager::adjust_offset` **@0x00555d30** gates its entire body on +`physics_obj->transient_state & 1` (CONTACT_TS) at 0x00555D52 — a remote in free +fall gets no interpolation correction at all. acdream ports that gate +(`InterpolationManager.cs:319-321`, `:346-349`) and the remote tick passes +`inContact: rm.Body.InContact` (`RuntimeRemotePhysicsUpdater.cs:269`, `:306`) — +but Link 1 forces `Contact` true unconditionally, so the gate never engages. +Another correct port defeated by the same forged input. + +### Do existing probes distinguish them? No. + +- `ACDREAM_PROBE_REMOTE_LANDING` (`PhysicsDiagnostics.cs:218-265`) fires only at + the two landing *edges*. It logs `contact`, `onWalkable`, `gravitySet`, + `resolveIsOnGround` — genuinely useful for confirming Links 3 and 4 at the + landing instant, but it emits nothing during the slide window and nothing at + the snap. It cannot see the wire bit or the classification. +- `ACDREAM_PROBE_RESOLVE` (`PhysicsEngine.cs:2640`) logs + `groundedIn / cp / hit / walkable` per resolve for every entity. It shows the + contact-plane state but not the *result's* `OnWalkable`, not the plane's + `Normal.Z`, and not the wire/classification side. It is also ~30 Hz × every + entity — impractical for a two-client session. +- `ACDREAM_REMOTE_VEL_DIAG` gives `[VEL_DIAG]` pace but no contact or + classification data. + +### The probe that would settle it + +One new per-accepted-Position line for remote GUIDs, emitted at the routing +site in `LiveEntityNetworkUpdateController` (both arms), plus one per-tick line +for the same GUID: + +**`[remote-slide-up]`** — guid, `update.IsGrounded` (the raw wire bit), +`earlyRemoteRoute?.Disposition`, `request.PlayerDistance`, `bodyToTarget`, +`willBeDrTicked`, `firstUp`, the `ApplyInterpolate` `Action` result +(`Snapped`/`Enqueued`), `rm.Airborne`, `Body.TransientState` Contact/OnWalkable, +`Body.State & Gravity`, `Body.Velocity`, `Body.ContactPlaneValid`, +`Body.ContactPlane.Normal.Z`, wire `worldPos`, `Body.Position`, plus +`Interp` queue depth and `_failCount` (needed to tell Candidate 1 from +Candidate 2). + +**`[remote-slide-tick]`** — guid, `resolveResult.InContact`, +`resolveResult.OnWalkable`, `resolveResult.IsOnGround`, +`Body.ContactPlane.Normal.Z`, `Body.Velocity`, `Body.Acceleration`, the +pre/post-integrate positions, and whether the body actually moved this tick. +Rate-limit to remotes whose `ContactPlaneValid && ContactPlane.Normal.Z < +PhysicsGlobals.FloorZ` (i.e. only while standing on something steep) so the +volume stays usable in a live two-client run. + +That single capture answers: which shape, which snap site, and whether the +contact plane the sweep finds on the roof is actually steep. + +--- + +## 3. What retail does — verified directly against the pseudo-C + +All citations independently read out of +`docs/research/named-retail/acclient_2013_pseudo_c.txt` for this report. + +### 3.1 Retail's observer runs full physics for *every* object — no player fork + +`CPhysics::UseTime` **@0x00509950** (pseudo-C :271481) iterates a +`LongHashIter` over the whole physics-object hash and calls +`update_object` on each entry (pseudo-C :271639-271650): + +``` +005099e0 class HashBaseData* curPtr_ = iter->curPtr_; +005099e5 CPhysicsObj::update_object(curPtr_); +005099ed if (curPtr_ == this->player) +005099f2 SmartBox::PlayerPhysicsUpdatedCallback(this->smartbox); +005099fa HashBaseIter::Next(this->iter); +``` + +The `curPtr_ == this->player` test only fires an extra callback. There is **no +local-vs-remote fork on the update itself**. + +`CPhysicsObj::update_object` **@0x00515d10** (pseudo-C :283950-284055) has +exactly one early-return gate (:283957): + +``` +00515d40 if ((this_3->parent != 0 || (this_3->cell == 0 || (this_3->state & 0x1000000) != 0))) +00515eeb this_3->transient_state &= 0xffffff7f; // clear ACTIVE +00515ef5 return; +``` + +`0x01000000` is `FROZEN_PS` (ACE `PhysicsState.Frozen = 0x01000000`, +`references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:32`). **There is no +`is_player`, `autonomous`, `server_controlled`, or `MOVEMENT_LOCKED` gate.** + +`0x01000000` is `FROZEN_PS` (retail header `acclient.h:2841`, `enum +PhysicsState`; cross-checked against ACE +`references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:32`). + +The rest is the object clock: `player_distance` is computed (:283963-283976, +this is retail's own `player_distance` field, the one +`MoveOrTeleport` later reads); `set_active(1)` when within 96 m or when the +object has no part array (:283983-283988); then dt gates — `< 0.0002` return, +`> 2.0` discard, then the MaxQuantum subdivision loop into +`UpdateObjectInternal` (:284035-284053). The 96 m test is a **distance LOD that +applies to the local player identically**, not an identity fork. + +The only autonomy-flavoured field on `CPhysicsObj` is +`last_move_was_autonomous`, written at 0x00514FE9 in `set_description` from +`PhysicsDesc::get_autonomous_movement` and read by +`CPhysicsObj::movement_is_autonomous` **@0x0050eb30** — whose only four callers +are in `CMotionInterp::apply_raw_movement`/`apply_current_movement` +(@0x0052888D, @0x005288ED, @0x0052894D, @0x00528991), i.e. deciding whether to +*send* movement upstream. **It is never read by the physics tick.** + +`UpdateObjectInternal` **@0x005156B0** (pseudo-C :283611) and +`UpdatePositionInternal` **@0x00512C30** (:280817) likewise contain no identity +fork. `UpdatePositionInternal`'s only state-dependent branch of interest is +`transient_state & 2` (ON_WALKABLE_TS, :280836), which decides whether the +animation root-frame delta is scaled by `m_scale` or by zero — the branch +acdream mirrors at `RuntimeRemotePhysicsUpdater.cs:133-135`, except that +acdream keys it on the client `rm.Airborne` bool instead of the transient. + +**Answer: a retail observer runs the complete transition/slide for a remote, +identically to the local player.** + +### 3.2 `MoveOrTeleport` returning 0 does not suspend that simulation + +`CPhysicsObj::MoveOrTeleport` **@0x00516330** (pseudo-C :284304-284366) — +verified verbatim: + +- `0x0051638E` — `if (arg4 != 0)` guards the entire near/far branch. +- `0x0051636D` — `return 0` when it does not. Nothing is written. +- `0x005163AF` — `InterpolateTo(this_1, arg2, IsMovingTo(this_1))` then + `return 1` @0x005163BE, on `player_distance < 96f`. +- `0x00516386` — cell-0/teleport branch, `teleport_hook` + flags-`0x1012` + `SetPosition`, `return 1` @0x00516438. + +Nothing on the `return 0` path touches `update_time`, `FROZEN_PS`, `parent`, +or `cell` — the three things `update_object` gates on. **The object keeps being +ticked by `CPhysics::UseTime` at full rate.** This is the single most important +retail fact for Bug B: retail's airborne no-op is safe *because* retail is +simulating the object locally. acdream copied the no-op without the simulation. + +The caller confirms the same: `SmartBox::HandleReceivedPosition` **@0x00453FD0**, +remote branch (pseudo-C :92995), is +`if (MoveOrTeleport(...) != 0) { …ConstrainTo @0x00454272 }` and then returns. +On the zero return it has done nothing but `unset_parent` @0x00454129 and the +`!HasAnims`-gated `SetPlacementFrame` @0x00454142 — neither of which touches +position, velocity, `FROZEN_PS`, or `ACTIVE_TS`. + +Incidental but worth recording: **`MoveOrTeleport` never references `arg5`**, +the wire velocity vector. Retail discards the broadcast velocity for remotes +entirely and relies on its own simulation — which is exactly the load acdream's +`RuntimeRemotePhysicsUpdater.cs:169` cannot carry. + +### 3.3 Retail derives `on_walkable` from the contact plane + +`CPhysicsObj::SetPositionInternal` **@0x00515330**, pseudo-C :283481-283509 +(read out directly): + +``` +00515430 if (arg2->collision_info.contact_plane_valid == 0) +00515437 eax_7 = (transient_state_1 & 0xfffffffe); // clear CONTACT_TS (0x1) +00515430 else +00515432 eax_7 = (transient_state_1 | 1); // set CONTACT_TS +0051543c this->transient_state = eax_7; +00515442 CPhysicsObj::calc_acceleration(this); +... +00515465 if ((eax_9 & 1) == 0) // not in contact +0051549f this->transient_state = (eax_9 & 0xfffffffd); // clear ON_WALKABLE (0x2) +005154a5 if ((eax_9 & 2) != 0) MovementManager::LeaveGround(...) +00515465 else +00515467 long double x87_r7_1 = this->contact_plane.N.z; +0051546d long double temp1_1 = PhysicsGlobals::floor_z; +00515478 if (contact_plane.N.z < floor_z) +0051548e CPhysicsObj::set_on_walkable(this, 0); +00515478 else +00515482 CPhysicsObj::set_on_walkable(this, 1); +``` + +Contact and on-walkable are **two independent facts**. A steep roof is Contact +and not on-walkable. acdream ports this correctly in +`PhysicsObjUpdate.ApplySetPositionContact` (`:30-56`) and +`IsWalkableContact` (`:20-21`) — it simply never calls them on the visible +remote path (Link 4). + +### 3.4 What actually makes retail slide on a steep contact + +Three pieces, all present and correct in acdream's Core — none of them reachable +by a grounded remote: + +1. **Gravity survives.** `CPhysicsObj::calc_acceleration` **@0x00510950** + (pseudo-C :278533; acdream's port is `PhysicsBody.cs:503-518`) zeroes + acceleration only when `CONTACT && ON_WALKABLE && !SLEDDING` (0x0051096B). On + a steep contact `ON_WALKABLE` is clear, so it falls through to the + `state & GRAVITY_PS (0x400)` test at 0x005109F4 → + `acceleration = (0, 0, PhysicsGlobals::gravity = -9.80000019)`. + **`GRAVITY_PS` is on in the `CPhysicsObj` constructor** — `state = 0x400C08` + at 0x00512508 (EDGE_SLIDE | LIGHTING_ON | GRAVITY | REPORT_COLLISIONS) — and + is thereafter set wholesale from the wire by `set_description`'s + `set_state(this, desc->state, 1)` @0x00514F40. `set_state` @0x00514DD0 + post-processes only lighting / nodraw / hidden; **it never masks GRAVITY**. + Retail never toggles the bit on a landing. +2. **Friction does not engage.** `CPhysicsObj::calc_friction` **@0x0050ee70** + (pseudo-C :276694) opens with `if ((this->transient_state & 2) != 0)` at + 0x0050EE7D — the *entire* function body, including the + `v -= (v·N)N` into-plane removal and the `pow(1-friction, dt)` damping, is + inside that `if`. On a non-walkable contact it returns immediately: no + friction, and the into-surface component is never projected out. acdream's + port has the identical gate (`PhysicsBody.cs:693-696`). +3. **The sweep projects the motion along the plane** — `CTransition` / + `SlideSphere` @0x00537440 and the `SetPositionInternal` sliding-normal tail + at 0x005154c2-0x005154e8. + +`UpdatePhysicsInternal` then integrates: `Velocity += Acceleration * dt` +unconditionally (acdream `PhysicsBody.cs:771`). That is the slide. + +### 3.5 Retail's interpolation is a per-frame *offset*, not the motion + +`PositionManager::adjust_offset` **@0x00555190** (pseudo-C :352090-352115) +chains `InterpolationManager::adjust_offset` → `StickyManager::adjust_offset` +→ `ConstraintManager::adjust_offset` into the same `Frame` that +`UpdatePositionInternal` then sends through the transition sweep. In retail the +interpolation nudges an object that is *already* moving under its own physics. +In acdream the interpolation catch-up is the object's *entire* motion +(`RemoteMotionCombiner.ComposeOffset`, `:40-76`), because Links 1-3 removed +everything else. + +--- + +## 4. Is the roof even walkable? — the classification agrees; the consumer does not + +**acdream's classifier agrees with retail.** `PhysicsObjUpdate.IsWalkableContact` +(`:20-21`) is `inContact && contactNormal.Z >= PhysicsGlobals.FloorZ` — the same +predicate as retail's `contact_plane.N.z < floor_z` test above, and the same as +retail's standalone `CPhysicsObj::is_valid_walkable` **@0x0050f530** (pseudo-C +:277180), which is literally `return N.z < floor_z ? 0 : 1`. + +**Constant check — with a caveat.** acdream `TransitionTypes.cs:1255` carries +`FloorZ = 0.6642f`, while `src/AcDream.Core/Rendering/Wb/TerrainUtils.cs:20` +carries `0.66417414618662751f`. Two copies of the same constant in our own tree, +differing by 2.6e-5 (about 0.002° of slope). Not load-bearing for Bug B, but it +is an unexplained internal divergence and should be unified on the longer value. + +**The literal 0.66417414 does NOT appear anywhere in the pseudo-C** — grep +returns nothing. `PhysicsGlobals::floor_z` lives at 0x008EDE5C and is computed +at static-init in `$E73` **@0x0070d920** (pseudo-C :805181), which Binary Ninja +renders as `floor_z = __fcos(3437.7467707849391)`. That rendering is a BN +artifact of the classic elided-numerator kind (`3437.74677` is +arcminutes-per-radian, so the real source is `cos( / 3437.7467…)`; BN +dropped the numerator, exactly the class of defect +`claude-memory/feedback_bn_decomp_field_names.md` warns about). The *value* is +corroborated by WorldBuilder and ACE agreeing on 0.66417414618662751, but a +byte decode of 0x0070D920 would be needed to pin it from the binary — see +NOT ESTABLISHED #6. + +So the "acdream thinks it's walkable, retail doesn't" hypothesis is **half +true, and its cause is not the threshold**. acdream's *sweep* would classify +the roof exactly as retail does. The forged `OnWalkable` at +`RuntimeRemotePhysicsUpdater.cs:150-154` overwrites that answer before the +sweep ever runs, and `Tick` discards the answer the sweep returns anyway +(Link 4). This is a plumbing defect, not a geometry or threshold defect. + +**NOT ESTABLISHED:** whether the specific house roof in the user's test +actually produces a contact plane with `Normal.Z < 0.6642` in acdream's +collision data. Buildings are GfxObj/Setup collision, and no capture of the +contact plane on that roof exists. The `[remote-slide-tick]` probe's +`ContactPlane.Normal.Z` field settles it. + +--- + +## 5. Is it in the register / ISSUES already? + +**Yes, partially — three rows and one issue, none of them complete.** + +| Record | What it covers | Gap | +|---|---|---| +| `docs/ISSUES.md` **#32** (2026-08-04 addendum, ~:9562) | Bug B named, root-caused to the *landing block's* unconditional `OnWalkable`; cites `SetPositionInternal` @0x00515330 :283501-283509 correctly | Does not name `RuntimeRemotePhysicsUpdater.cs:150-154` (the dominant per-tick force), does not name `:169` (velocity zeroing), and treats Link 3 (Gravity) as a Bug A concern only. The stated fix target is therefore insufficient. | +| register **AP-87** (`:242`) | The 4 m snap; its risk column *already predicted this exact symptom* and was updated 2026-08-04 with the live observation | Correct and current. AP-87 is the blip's mechanism, not its cause — do not "fix" AP-87. | +| register **AP-81** (`:235`) | Remote VectorUpdate's non-retail Airborne/Gravity dance; risk column literally says "grounded remotes carry a non-retail state word" | Its file:line column lists only the VectorUpdate handler and the landing blocks. It does **not** list `RuntimeRemotePhysicsUpdater.cs:150-154`/`:169` — the per-tick unconditional force and velocity zeroing have **no register row of their own** and are not covered by AP-81's cited sites. Register rule 1 violation; the row needs extending in whichever commit next touches that file. | +| register **AD-10** (`:122`) | Remote slope projection is terrain-normal-only (`RemoteMotionCombiner.ComposeOffset :65-73`, `ComputeOffset :163-168`), cannot see building/EnvCell geometry; already amended 2026-08-04 to name Bug B | Correct. Confirms that even a corrected `OnWalkable` gets no help from this path on a house roof. | + +**Verified `#32` is NOT a route-4a regression, independently:** the landing +block's `TransientState |= Contact | OnWalkable` and the per-tick force at +`:150-154` both predate C4. The per-tick force carries the comment "Forces +OnWalkable + Contact so the gate in `apply_current_movement` always succeeds" +(`:138-140`) — a #184-Slice-2b-era construct, unrelated to route 4a. Do not +revert `44830a0e` or `7f1c1f5a`. + +**DO-NOT-RETRY check** (`claude-memory/project_physics_collision_digest.md`): +the proposal in §6 does not appear in any DO-NOT-RETRY table. Closest +neighbours, all distinct: the AD-25 landing `Velocity.Z = 0` hand-zero +(:145-151) — this proposal *removes* a velocity zeroing rather than adding one, +and does not touch the landing reflect; "do not seed transition contact from a +caller's `isOnGround` bool when a body is present" (:149-150) — this proposal +moves the remote *toward* body-derived contact, which is the same direction; +#269's slope-slide residual (:152-160) — a *local-player* decay-curve question +whose code is byte-exonerated, unrelated to the remote plumbing here. + +--- + +## 6. Proposed fix + +### The one-line summary + +The remote tick already runs retail's sweep and the sweep already computes +retail's answer. **Stop forging the inputs and start consuming the outputs** — +i.e. make the visible remote path use the same +`PhysicsObjUpdate.CommitSetPositionTransition` seam that `TickHidden` and every +other body in the codebase already use. + +### Concrete targets + +1. **`src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:150-154`** — + delete the unconditional `Contact | OnWalkable` force from the grounded + branch. Keep `Active`. Retail's equivalent state is written only by + `SetPositionInternal` from the contact plane + (`@0x00515330`, 0x00515430 / 0x00515465-0x0051548e). +2. **`RuntimeRemotePhysicsUpdater.cs:420-477`** — replace the bare + `HandleAllCollisions` call with `PhysicsObjUpdate.CommitSetPositionTransition + (body, resolveResult.InContact, resolveResult.OnWalkable, …, previousContact, + previousOnWalkable, rm.Movement.HitGround, rm.Motion.LeaveGround, isCurrent)` + — byte-identical in shape to `TickHidden.cs:778-795`, which already does + exactly this. `CommitSetPositionTransition` calls `HandleAllCollisions` + itself (`PhysicsObjUpdate.cs:104-110`), so this is a substitution, not an + addition. Retail order is `SetPositionInternal` contact/walkable → + `HitGround`/`LeaveGround` → `handle_all_collisions` @0x005154FE. +3. **`RuntimeRemotePhysicsUpdater.cs:493-495`** — the landing test currently + uses `resolveResult.IsOnGround`, which is `inContact || …` + (`PhysicsEngine.cs:2660`) and therefore true on a steep roof. It must be + `resolveResult.OnWalkable`. Retail's ground edge is `set_on_walkable`, not + contact. +4. **`RuntimeRemotePhysicsUpdater.cs:169`** — the `Velocity = Zero` must become + conditional on the body actually being on walkable ground, or be deleted in + favour of letting `calc_friction` (which retail gates on `OnWalkable`, + `PhysicsBody.cs:693-696`) do the decay. Deleting it outright is the + retail-faithful shape; making it conditional is the smaller step. +5. **`RuntimeRemotePhysicsUpdater.cs:571-576` and + `LiveEntityNetworkUpdateController.cs:2110-2116`** — stop clearing + `PhysicsStateFlags.Gravity`. Retail keeps GRAVITY_PS for the object's whole + life and gates gravity acceleration on the Contact transient inside + `calc_acceleration` (already correct in `PhysicsBody.cs:505-517`). This + retires the bulk of register row **AP-81** and is shared with **Bug A**'s H1. +6. **`LiveEntityNetworkUpdateController.cs:2023-2024`** — the landing block's + force, the site ISSUES.md #32 names. Necessary but, alone, useless: `:150-154` + re-asserts it on the next tick. + +7. **Register bookkeeping, same commit.** `AP-81`'s file:line column must gain + `RuntimeRemotePhysicsUpdater.cs:150-154` and `:169` (§5). If items 1-5 land, + AP-81 is largely retired and the row should be deleted or narrowed in the + same commit per the register's rule 1. `AP-87` stays — it is the backstop, + not the bug. + +**Explicitly NOT in scope:** `src/AcDream.Core/Physics/InterpolationManager.cs`. +Its stall blip (§2 Candidate 2) is a faithful port of retail +`InterpolationManager::UseTime` @0x00555f20 and is behaving correctly on a +frozen body. Touching it would be a symptom-site guard. + +### Ordering and prerequisites + +Items 1-4 are one coherent change and must land together — any subset leaves +either a forged input or a discarded output. Item 5 is separable and is shared +with Bug A. Item 6 falls out of item 1 (both sites express the same wrong idea). +Item 7 is bookkeeping and rides whichever commit lands 1-4. + +### Blast radius + +- **Every remote — players and NPCs alike.** Slice 2b deliberately collapsed the + player/NPC fork (`RuntimeRemotePhysicsUpdater.cs:110-117`); there is one path. + A remote will now genuinely fall, slide, and be subject to friction. This is + the intent, and it is what makes it risky: **the #184 invisible-but-solid + monster lived in this neighbourhood.** AP-87's 4 m snap stays as the backstop + and must not be weakened in the same change (AP-137 explicitly warns that + weakening it turns the leftover arm into a silent-freeze path). +- **NPC free-fall / knockback** — the route 4a acceptance criterion "push a + monster off a ledge, confirm it falls and lands without hovering" + (`2026-08-03-c4-route-4a-contract.md:174-183`) is directly exercised by items + 3 and 5 and must be re-run. +- **Local player** — untouched. `PlayerMovementController` constructs its body + with `State = Gravity | ReportCollisions` permanently + (`PlayerMovementController.cs:689`) and already commits contact from the + sweep. No shared code changes. +- **Projectiles** — untouched. `RuntimeProjectilePhysicsUpdater` has its own + stepper and its own state handling. +- **Hidden remotes** — untouched; `TickHidden` already does the right thing and + is the template. +- **Sticky melee (TS-44) and de-overlap (#184/AP-86)** — the shadow-follows- + resolved sync at `:618-630` reads `rm.Body.Position` after the resolve; a body + that now genuinely moves under gravity will re-flood its shadow more often. + Perf note, not correctness, but worth measuring in a packed town. +- **The interpolation contact gate goes live.** `InterpolationManager.AdjustOffset`'s + `inContact` early-return (`:319-321`, `:346-349` — retail's @0x00555D52 + CONTACT_TS gate) currently never engages because Link 1 forges the bit. After + item 1 it will, and an out-of-contact remote will stop receiving interpolation + corrections mid-fall — which is retail-correct, and which also means Candidate 2 + can no longer accumulate fail counts against a falling body. Expect the + observable timing of any residual blip to change. + +### Does it need live cdb evidence rather than a code change? + +**The retail side does not** — §3 establishes retail's behaviour from the +pseudo-C conclusively, with no ambiguity requiring a runtime trace. `CPhysics::UseTime` +iterating every object, `update_object`'s three-condition gate, `MoveOrTeleport`'s +bare `return 0`, and `SetPositionInternal`'s floor_z comparison are all read +directly and are unambiguous. + +**The acdream side does**, for one thing only: **which of Shape A / Shape B is +happening on the wire**, and whether the roof's contact plane in acdream's own +collision data is actually steep (§4's NOT ESTABLISHED). That is an *acdream* +capture — the `[remote-slide-up]` / `[remote-slide-tick]` probe of §2 — not a +retail cdb attach. Retail cdb is not the right tool here and would not answer +either question. + +The honest recommendation: **land the probe, take one two-client capture, then +implement items 1-4 together.** Implementing before the capture is defensible +given how complete the code-side chain is, but the capture costs one session +and tells us whether the wire is *also* starving the path (Shape A), which +changes whether the `NoPositionOperation` branch needs its own follow-up. + +--- + +## Shared root with Bug A? + +**Partially — one of three links is shared; the rest are not.** + +Shared: **Link 3, the Gravity clear.** Bug A's H1 +(`2026-08-04-remote-landing-investigation.md:55-88`) is that the Gravity bit is +already clear when `HitGround()` runs, so `CMotionInterp::HitGround`'s +`state & 0x400` gate silently no-ops and the Falling pose never exits. Bug B's +Link 3 is that the same clear removes the gravity acceleration that would drive +the slide. **Item 5 of the fix addresses both.** + +Also shared: the **misclassified landing edge** (item 3). If the remote "lands" +on a steep roof because `IsOnGround` is contact-derived, it runs the whole +landing block — `HitGround`, Gravity clear, pose transition — on a surface +retail is still sliding it down. That is a plausible amplifier for Bug A's +symptom *specifically in the roof scenario*, though Bug A also occurs on flat +ground, where it must have another cause. + +Not shared: Links 1, 2, and 4 (the per-tick force, the velocity zeroing, and +the uncommitted sweep result) are pure position/physics and have no animation +consequence. Bug A's H2 (no `DefaultSink` bound) and H3 (scheduler-side) have no +Bug B analogue. + +--- + +## NOT ESTABLISHED + +1. **Which shape (A or B) the wire produces during a retail sender's roof + slide** — i.e. whether ACE emits `IsGrounded == false` for the whole slide. + Requires the `[remote-slide-up]` capture. Depends on whether ACE's *server-side* + physics classifies a house roof as non-walkable, which was not traced. +2. **Whether the specific roof produces a contact plane with `Normal.Z < + 0.6642` in acdream's collision data.** Requires the `[remote-slide-tick]` + capture's `ContactPlane.Normal.Z` field. +3. **Whether items 1-4 are sufficient** to reproduce retail's slide, or whether + the `#173` remote collision-velocity reflect (shipped but its gate folded + into the never-run Campaign P matrix scenario 8) and AD-10's terrain-only + projection also need work. ISSUES.md #32 already flags both as open + dependencies and that assessment is confirmed here. +4. **Whether ACE relays a `0xF74E` VectorUpdate at all during a slide.** If it + does not, the sender's slide velocity never reaches acdream even in + principle, and the local simulation is the only possible source — which + strengthens the fix but was not verified against ACE's broadcast conditions. +5. **Whether ACE's server-side physics classifies a house roof as + non-walkable.** ACE emits `IsGrounded` from its own + `PhysicsObj.TransientState & OnWalkable` (`PositionPack.cs:72-73`), so + Shape A requires ACE's server physics to agree with retail's `floor_z` test + on building geometry. Not traced. +6. **`PhysicsGlobals::floor_z`'s exact value from the binary.** The literal is + absent from the pseudo-C and its static-init is a BN-mangled `fcos` + (§4). The value is corroborated by two independent references but not by a + primary byte decode of 0x0070D920. +7. **The polarity of `MoveOrTeleport`'s `player_distance` vs `96.0f` compare** + at 0x00516393 — BN emits `bool p = unimplemented {test ah, 0x5}` and its + synthesized `fcom` operand ordering is inconsistent across this dump. Nothing + in this diagnosis depends on it: both arms `return 1` and both leave the + object in the simulation set, and the observer in the user's test is far + inside 96 m either way. diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 314a4e93..74030343 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -198,13 +198,25 @@ internal sealed class LiveEntityNetworkUpdateController // Retail's spawn contact comes from the FIRST GRAVITY FRAME, not the // placement itself: every retail CPhysicsObj simulates, so a freshly // placed creature falls the few centimetres onto the floor and the - // transition's touch grants the contact plane. Our stationary remotes - // never run a physics frame (the DR tick resolves only movers), so - // the settle is compressed here: a short downward sweep from the - // server position. Its touch handler produces exactly the state - // retail's first frame would (position snapped onto the floor, - // contact plane + CONTACT/ON_WALKABLE committed below). A sweep that - // finds no floor (true airborne spawn) leaves the body airborne. + // transition's touch grants the contact plane. Our remotes reach that + // state SLOWLY or not at all — the DR tick only sweeps when the + // composed candidate actually moved, so a remote spawned exactly on + // its floor never sweeps and a remote spawned above one needs however + // many ticks gravity takes to close the gap. The settle is therefore + // compressed here: a short downward sweep from the server position. + // Its touch handler produces exactly the state retail's first frame + // would (position snapped onto the floor, contact plane + + // CONTACT/ON_WALKABLE committed below). A sweep that finds no floor + // (true airborne spawn) leaves the body airborne. + // + // Bug B (2026-08-04) weakened — but did not remove — the reason this + // exists. The deleted per-tick `Contact | OnWalkable` forge used to + // make a stationary remote's transients permanent, so a contact-free + // remote could NEVER settle on its own; now gravity survives and one + // WILL settle by itself after a few ticks of falling. This compressed + // settle is what keeps it from spending those ticks visibly + // contact-free, which is the #270 window (`contact_allows_move` + // @0x00528dd0 refuses action animations without both transients). if (!AcDream.Core.Physics.SpawnPlacementSettler.TrySettle( _physicsEngine, remote.Body, @@ -1028,21 +1040,38 @@ internal sealed class LiveEntityNetworkUpdateController ArgumentNullException.ThrowIfNull(placementDrive); ArgumentNullException.ThrowIfNull(canonical); ArgumentNullException.ThrowIfNull(remote); + // Bug B (2026-08-04): stamp the GUID that any [remote-slide-*] line + // emitted from inside this synchronous routing window belongs to — + // ApplyInterpolate (blip producer Candidate 1) has no GUID of its own. + // TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family. + AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution( + canonical.ServerGuid); if (remote.Airborne) { // Verbatim from the pre-4a branch, queue deliberately NOT // cleared: the arc integrates locally (K-fix15), and clearing - // stale waypoints is owned by the per-tick LANDING detection - // (RuntimeRemotePhysicsUpdater.cs:497-502), not by this snap. + // stale waypoints is owned by the per-tick LANDING detection — + // the `!previousOnWalkable && finalOnWalkable` arm of + // RuntimeRemotePhysicsUpdater.Tick's SetPositionInternal commit, + // whose `rm.Interp.Clear()` is register row AP-139 — not by this + // snap. Cited by SYMBOL on purpose: the same reference was a line + // range twice and went stale both times, once within a single + // 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 (:1059) nor the three `Airborne = !Body.OnWalkable` - // sites do. 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. + // 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. remote.Body.Position = worldPos; remote.Body.Orientation = rotation; return new RemoteContactRouting( @@ -1257,6 +1286,31 @@ internal sealed class LiveEntityNetworkUpdateController return; } + // Bug B (2026-08-04) — [remote-slide-vec]. NOT ESTABLISHED #4 asks + // whether ACE relays a 0xF74E at all while a sender slides; the + // ABSENCE of these lines across a captured slide window is the + // answer, so this sits on the committed path rather than inside the + // +Z airborne branch below (a downhill slide has Velocity.Z < 0 and + // would never reach it). willMarkAirborne restates that branch's own + // test so the log states the outcome rather than making the reader + // re-derive it. Pure reads. TEMPORARY — strip with the probe family. + if (AcDream.Core.Physics.PhysicsDiagnostics.ShouldLogRemoteSlide( + update.Guid)) + { + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideVector( + guid: update.Guid, + wireVelocity: update.Velocity, + wireOmega: update.Omega, + willMarkAirborne: update.Velocity.Z > 0.5f, + airborneBefore: rm.Airborne, + contact: rm.Body.InContact, + onWalkable: rm.Body.OnWalkable, + gravity: rm.Body.HasGravity, + bodyVelocity: rm.Body.Velocity, + contactPlaneValid: rm.Body.ContactPlaneValid, + contactPlaneNormalZ: rm.Body.ContactPlane.Normal.Z); + } + // Mark airborne when the launch has meaningful +Z. Threshold // 0.5 m/s rejects noise / horizontal-only updates (server might // also use VectorUpdate for non-jump events). The per-tick @@ -1265,12 +1319,26 @@ internal sealed class LiveEntityNetworkUpdateController if (update.Velocity.Z > 0.5f) { rm.Airborne = true; - // Clear ground-contact bits + enable gravity so calc_acceleration - // returns (0, 0, -9.8) instead of zero. UpdatePhysicsInternal then - // produces the parabolic arc. + // Clear the ground-contact transients so calc_acceleration + // (0x00510950) releases gravity and UpdatePhysicsInternal produces + // the parabolic arc. Retail reaches the same state one frame later + // through check_contact (0x0050F5B0) failing on the ascending + // velocity; clearing them here is the AP-81 head start, and it is + // what keeps the per-tick `set_on_walkable` edge from ALSO firing + // LeaveGround for this same departure. + // + // Bug B (2026-08-04): the `State |= Gravity` that used to follow is + // DELETED. GRAVITY_PS is a persistent object property owned by the + // wire — retail's CPhysicsObj constructor seeds it (state 0x400C08 + // @0x00512508) and set_description's set_state (0x00514DD0) assigns + // the description's state wholesale without ever masking it. Now + // that neither landing block clears the bit, manufacturing it here + // would be the only remaining non-retail gravity write, and it + // would mask a server that genuinely sent a gravity-free state. + // ACE agrees: PhysicsGlobals.DefaultState and the player login + // state both carry PhysicsState.Gravity. rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable); - rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity; // R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism. // The remote's ground departure fires LeaveGround (0x00528b00): @@ -1924,6 +1992,53 @@ internal sealed class LiveEntityNetworkUpdateController remoteConstraintHost); } + // Bug B (2026-08-04) — [remote-slide-up]. This is the ONE point + // both remote arms pass through, and it deliberately sits AHEAD of + // the two IsAirborneNoOperation early returns below: in the + // diagnosis's Shape A (ACE reports IsGrounded == false for the + // whole slide) acdream writes nothing at all, so a line emitted + // after those returns would leave the entire slide window blank + // and NOT ESTABLISHED #1 unanswerable. `wireGrounded` is the raw + // ACE PositionFlags.IsGrounded bit for this packet. + // docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md §2. + // Pure reads. TEMPORARY — strip with the probe family. + if (AcDream.Core.Physics.PhysicsDiagnostics.ShouldLogRemoteSlide( + update.Guid)) + { + (int slideQueueDepth, int slideFailCount) = + rmState.Interp.DiagnosticInterpolationState; + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideUp( + guid: update.Guid, + wireGrounded: update.IsGrounded, + wireVelocity: update.Velocity, + disposition: earlyRemoteRoute is { } slideRoute + ? slideRoute.Disposition.ToString() + : "unclassified", + playerDistance: _playerController is { } slideController + ? System.Numerics.Vector3.Distance( + worldPos, + slideController.Position) + : null, + bodyToTarget: System.Numerics.Vector3.Distance( + rmState.Body.Position, + worldPos), + bodySnapThreshold: + RuntimeRemoteSteadyStatePosition.DiagnosticBodySnapThreshold, + willBeDrTicked: WillAdvanceRemoteMotion(update.Guid, rmState), + firstUp: rmState.LastServerPosTime <= 0.0, + airborne: rmState.Airborne, + contact: rmState.Body.InContact, + onWalkable: rmState.Body.OnWalkable, + gravity: rmState.Body.HasGravity, + bodyVelocity: rmState.Body.Velocity, + contactPlaneValid: rmState.Body.ContactPlaneValid, + contactPlaneNormalZ: rmState.Body.ContactPlane.Normal.Z, + wirePosition: worldPos, + bodyPosition: rmState.Body.Position, + interpQueueDepth: slideQueueDepth, + interpFailCount: slideFailCount); + } + // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for // player remotes. Mirrors CPhysicsObj::MoveOrTeleport // (acclient @ 0x00516330) — airborne no-op, far-snap, near @@ -2013,15 +2128,36 @@ internal sealed class LiveEntityNetworkUpdateController } // ── LANDING TRANSITION ──────────────────────────────────────── - // First IsGrounded=true UP after rmState.Airborne signals landed. - // Clear airborne flags, hard-snap to authoritative landing position, - // clear interpolation queue (any pre-jump waypoints are stale). + // 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). + // 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). + // `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. + // + // 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 + // clear the Gravity STATE bit. All four are deleted: + // • the velocity zero discarded the authoritative vector ACE + // delivered (retail MoveOrTeleport 0x00516330 never reads or + // writes the wire velocity for a remote at all); + // • the transient assert forged the two facts retail derives + // from the contact plane in SetPositionInternal + // (0x00515430 / 0x00515465-0x0051548E) — on a steep roof it + // declared a non-walkable surface walkable; + // • HitGround has exactly ONE retail source, + // `set_on_walkable(1)` @0x00511358, which the per-tick + // SetPositionInternal commit now owns. Firing it from here + // as well would double-dispatch the landing re-apply; + // • 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) { - rmState.Airborne = false; - rmState.Body.Velocity = System.Numerics.Vector3.Zero; - rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable; rmState.Interp.Clear(); rmState.Body.Position = worldPos; rmState.Body.Orientation = rot; @@ -2052,16 +2188,10 @@ internal sealed class LiveEntityNetworkUpdateController entity.ParentCellId = rmState.CellId; entity.Rotation = rmState.Body.Orientation; - // #161: retail landing = MovementManager::HitGround - // (minterp → moveto, 0x00524300 — the R5-V5 facade - // relay) with the Gravity state bit STILL SET - // (CMotionInterp::HitGround gates on state&0x400). The - // re-apply dispatches the PRESERVED pre-fall forward - // command → landing link → cycle. This replaces the - // forced SetCycle, which read the then-clobbered - // ForwardCommand (Falling) and re-set the pose it meant - // to clear. See the twin block in TickAnimations - // (VU.land). + // The motion bindings still have to exist before the next + // per-tick commit can dispatch this remote's ground edge. + // Only the HitGround CALL moved (see the block comment); + // binding is the packet's own responsibility. if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) && aeForLand.Sequencer is not null) { @@ -2069,11 +2199,14 @@ internal sealed class LiveEntityNetworkUpdateController } // Bug A investigation (2026-08-04, docs/ISSUES.md #32): - // capture the exact state HitGround is about to act on — - // see PhysicsDiagnostics.LogRemoteLanding for the field - // list and PhysicsDiagnostics.ProbeRemoteLandingEnabled - // for the discriminator table. TEMPORARY — strip once - // the live-test run has landed. + // the packet-side half of the landing capture. Bug B moved + // the HitGround call itself onto the per-tick + // `set_on_walkable` edge, so `hitGroundInvoked` is now + // false here and the "per-tick" pair is the one that + // reports the dispatch. This line still records the exact + // state the authoritative landing snap installed, which is + // what the discriminator table reads it for. + // TEMPORARY — strip once the live-test run has landed. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) { bool gravitySetForProbe = rmState.Body.HasGravity; @@ -2093,26 +2226,23 @@ internal sealed class LiveEntityNetworkUpdateController AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp( "controller", update.Guid); } - } - - ulong landingStateAuthorityVersion = - positionRecord.StateAuthorityVersion; - rmState.Movement.HitGround(); - if (!IsCurrentPositionOwner(entity) - || !ReferenceEquals( - positionRecord.RemoteMotionRuntime, - rmState)) - { - return; - } - // DR bookkeeping only (partner of the jump-start - // `State |= Gravity`). - if (_liveEntities.IsCurrentStateAuthority( - positionRecord, - landingStateAuthorityVersion)) - { - rmState.Body.State &= - ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + // Zero the sink-dispatch latches before reading them + // back. Nothing at THIS site dispatches — the arming + // call lives only next to the per-tick HitGround — so + // without this the line below would print + // sinkApplyCalls/sinkLastMotion/sinkLastResult left + // over from a previous per-tick capture on this + // thread and invite a reader to attribute them to the + // packet. Zeros are the honest report here. + AcDream.Core.Physics.PhysicsDiagnostics + .BeginRemoteLandingDispatchCapture(); + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter( + site: "controller", + guid: update.Guid, + hitGroundInvoked: false, + sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0, + sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0, + forwardCommand: rmState.Motion.InterpretedState.ForwardCommand); } return; } diff --git a/src/AcDream.Core/Physics/InterpolationManager.cs b/src/AcDream.Core/Physics/InterpolationManager.cs index 9f7f192f..48828df8 100644 --- a/src/AcDream.Core/Physics/InterpolationManager.cs +++ b/src/AcDream.Core/Physics/InterpolationManager.cs @@ -124,6 +124,18 @@ public sealed class InterpolationManager /// Current waypoint count (visible to tests for cap verification). internal int Count => _queue.Count; + /// + /// Bug B (2026-08-04) read-only diagnostic view for the + /// ACDREAM_PROBE_REMOTE_SLIDE family. The queue depth plus the + /// live node_fail_counter is what lets a reader see blip producer + /// Candidate 2 ARMING (fail count climbing toward + /// ) from the per-packet + /// [remote-slide-up] line, before it fires. Pure read; no + /// production consumer. TEMPORARY — strip with the probe family. + /// + public (int Depth, int FailCount) DiagnosticInterpolationState + => (_queue.Count, _failCount); + /// /// Stop interpolating: drain queue and reset all stall state to sentinel /// values. Retail StopInterpolating (@ 0x00555950). @@ -459,6 +471,22 @@ public sealed class InterpolationManager { InterpolationNode tail = _queue.Last!.Value; Vector3 tailDelta = tail.TargetPosition - currentBodyPosition; + // Bug B (2026-08-04) blip producer CANDIDATE 2 — observation only. + // docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md §2 + // establishes this snap as a FAITHFUL port of retail + // InterpolationManager::UseTime @0x00555f20 firing correctly on a + // body frozen upstream, and rules it explicitly out of scope for + // any fix. The call reads only values already computed on this + // line and is self-guarded on ProbeRemoteSlideEnabled, so it + // changes neither the branch nor its result. TEMPORARY — strip + // with the ACDREAM_PROBE_REMOTE_SLIDE family. + PhysicsDiagnostics.LogRemoteSlideStallSnap( + failCount: _failCount, + threshold: StallFailCountThreshold, + queueDepth: _queue.Count, + bodyPosition: currentBodyPosition, + tailPosition: tail.TargetPosition, + distanceToHead: dist); Clear(); return new InterpolationStep( true, diff --git a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs index b9e958df..d1bdf41f 100644 --- a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs +++ b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs @@ -34,6 +34,12 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink public bool ApplyMotion(uint motion, float speed) { uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed)); + // Bug A probe ([remote-landing-after], ACDREAM_PROBE_REMOTE_LANDING): + // the MotionTableManagerError code is discarded by this bool return, + // so hand it to the diagnostic latch before it is lost. Self-guarded + // — one flag test when the probe is off, no behaviour change either + // way. TEMPORARY, strips with the rest of the probe family. + PhysicsDiagnostics.RecordRemoteLandingDispatch(motion, result); return result == MotionTableManagerError.Success; } diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 2eef3b31..1b1ae1e1 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -264,6 +264,498 @@ public static class PhysicsDiagnostics $"[remote-landing-gate] site={site} guid=0x{guid:X8} t={Environment.TickCount64} NOOP gravityAlreadyClear=true")); } + // ── [remote-landing-after] — the OUTCOME half of the Bug A probe ────── + // + // docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md §6.1: the + // [remote-landing] line above reads state immediately BEFORE + // MovementManager.HitGround, so it cannot separate (a) the edge never + // firing, from (b) HitGround firing and something re-asserting Falling, + // from (c) the motion-table sink refusing the cycle. The companion line + // below reads the same entity immediately AFTER the call, at the same + // two sites, and pairs 1:1 with it (same site + guid, next line for + // that guid). + // + // Dispatch capture: MotionTableDispatchSink.ApplyMotion discards the + // MotionTableManagerError code (it returns bool) and HitGround itself + // returns void, so nothing at the call site can observe what the sink + // did. These [ThreadStatic] latches carry it across the synchronous + // HitGround call without changing any signature: the call site calls + // BeginRemoteLandingDispatchCapture() right before HitGround, the sink + // records each ApplyMotion, and LogRemoteLandingAfter reports the count + // plus the LAST ApplyMotion — which for the landing re-apply + // (ApplyInterpretedMovement, MotionInterpreter.cs:2842-2903) is the + // decisive one: either Falling (:2867) or InterpretedState.ForwardCommand + // (:2878). Thread-static because the whole window is synchronous on the + // ticking thread, and headless hosts tick several sessions in parallel. + // + // Every member here is inert unless ProbeRemoteLandingEnabled is true. + // TEMPORARY — strip with the rest of the ACDREAM_PROBE_REMOTE_LANDING + // family once the discriminating live capture has landed. + + [ThreadStatic] private static int _remoteLandingApplyCalls; + [ThreadStatic] private static uint _remoteLandingLastApplyMotion; + [ThreadStatic] private static uint _remoteLandingLastApplyResult; + + /// + /// Arm the per-call sink-dispatch capture read back by + /// . Call immediately before + /// MovementManager.HitGround. No-op unless + /// . + /// + public static void BeginRemoteLandingDispatchCapture() + { + if (!ProbeRemoteLandingEnabled) return; + _remoteLandingApplyCalls = 0; + _remoteLandingLastApplyMotion = 0; + _remoteLandingLastApplyResult = 0; + } + + /// + /// Record one IInterpretedMotionSink.ApplyMotion dispatch and its + /// raw MotionTableManagerError code. Called by + /// ; self-guarded, so it is + /// a single flag test when the probe is off. + /// + public static void RecordRemoteLandingDispatch(uint motion, uint result) + { + if (!ProbeRemoteLandingEnabled) return; + _remoteLandingApplyCalls++; + _remoteLandingLastApplyMotion = motion; + _remoteLandingLastApplyResult = result; + } + + /// + /// Emit one [remote-landing-after] line for the landing edge whose + /// [remote-landing] line was just written. Caller MUST guard with + /// if (!ProbeRemoteLandingEnabled) return; before calling, and MUST + /// emit it before any post-HitGround ownership re-check can return — a + /// before-line with no after-line therefore means the call site threw. + /// is if a + /// gate short-circuited between the two lines (no such gate exists at + /// either site today; the field exists so the absence is stated rather + /// than inferred from a missing line). + /// + public static void LogRemoteLandingAfter( + string site, + uint guid, + bool hitGroundInvoked, + uint sequencerStyle, + uint sequencerMotion, + uint forwardCommand) + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[remote-landing-after] site={0} guid=0x{1:X8} t={2} " + + "hitGroundInvoked={3} seqStyle=0x{4:X8} seqMotion=0x{5:X8} " + + "fwdCmd=0x{6:X8} sinkApplyCalls={7} sinkLastMotion=0x{8:X8} " + + "sinkLastResult=0x{9:X8}", + site, guid, Environment.TickCount64, + hitGroundInvoked, sequencerStyle, sequencerMotion, + forwardCommand, _remoteLandingApplyCalls, + _remoteLandingLastApplyMotion, _remoteLandingLastApplyResult)); + } + + // ── [remote-slide-*] — Bug B (remote ledge/roof slide) capture ──────── + // + // docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md §2 names TWO + // live blip producers and states that NO existing probe distinguishes + // them: + // • Candidate 1 — AP-87's `bodyToTarget > 4 m` body snap in + // RuntimeRemoteSteadyStatePosition.ApplyInterpolate (:129-137). + // • Candidate 2 — InterpolationManager's own retail-faithful + // `node_fail_counter > 3` snap-to-tail (:458-467; retail + // InterpolationManager::UseTime @0x00555f20). That code is CORRECT + // and is only reachable because §1 froze the body; this probe + // OBSERVES it and must never be read as a reason to change it. + // Both emit `[remote-slide-snap]` with a distinct `producer=` tag, so + // one grep finds every blip and the tag alone answers "which one". + // + // The same family also settles the diagnosis's two load-bearing NOT + // ESTABLISHED items: + // • #1 (Shape A vs Shape B) — `[remote-slide-up] wireGrounded=` is the + // raw ACE PositionFlags.IsGrounded bit for the accepted packet, + // emitted at the ONE routing point both remote arms pass through, + // AHEAD of the NoPositionOperation early returns, so a Shape-A slide + // (every packet `wireGrounded=false disp=NoPositionOperation`) is + // visible even though acdream writes nothing for it. + // • #2 (is the roof steep in OUR collision data) — + // `[remote-slide-tick] bodyCpNz=/rsCpNz=` against `floorZ=`. + // `[remote-slide-vec]` covers the 0xF74E half of NOT ESTABLISHED #4: an + // absence of lines during a slide is itself the answer. + // + // Pure reads only. Nothing here gates, orders, or mutates production + // state; the throttle dictionary and the attribution latch are + // probe-owned and [ThreadStatic] because a headless host ticks several + // sessions in parallel. + // + // TEMPORARY — strip the whole ACDREAM_PROBE_REMOTE_SLIDE family once the + // two-client roof capture has landed. + + private static readonly string? RemoteSlideProbeRaw = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_REMOTE_SLIDE"); + + /// + /// Initial state from ACDREAM_PROBE_REMOTE_SLIDE. 1 enables + /// the family for every remote; a comma-separated hex GUID list (e.g. + /// 0x50000123,0x8001ABCD) enables it only for those GUIDs, which is + /// what keeps a live two-client capture readable. Unset/empty = inert. + /// + public static bool ProbeRemoteSlideEnabled { get; set; } = + !string.IsNullOrWhiteSpace(RemoteSlideProbeRaw); + + /// + /// Optional GUID allow-list for . + /// Empty means "every remote". + /// + public static IReadOnlySet ProbeRemoteSlideGuids { get; set; } = + RemoteSlideProbeRaw is null || RemoteSlideProbeRaw.Trim() == "1" + ? new HashSet() + : ParseHexIdList(RemoteSlideProbeRaw); + + /// + /// The single gate every [remote-slide-*] call site checks first. + /// One static bool read plus (only when enabled) one set lookup. + /// + public static bool ShouldLogRemoteSlide(uint guid) => + ProbeRemoteSlideEnabled + && (ProbeRemoteSlideGuids.Count == 0 + || ProbeRemoteSlideGuids.Contains(guid)); + + // Neither InterpolationManager nor RuntimeRemoteSteadyStatePosition has + // access to a server GUID (RemoteMotion does not carry one), and + // claude-memory/feedback_probe_identity_attribution.md makes the GUID + // mandatory on a per-entity probe. Rather than widen either production + // signature, the two per-remote windows that call into them stamp this + // latch first — the same [ThreadStatic] shape the [remote-landing-after] + // dispatch capture already uses, and for the same reason (the whole + // window is synchronous on the ticking thread). + [ThreadStatic] private static uint _remoteSlideAttributionGuid; + + /// + /// Stamp the GUID that any [remote-slide-*] line emitted from + /// inside the following synchronous per-remote window belongs to. No-op + /// unless . + /// + public static void BeginRemoteSlideAttribution(uint guid) + { + if (!ProbeRemoteSlideEnabled) return; + _remoteSlideAttributionGuid = guid; + } + + /// The GUID stamped by the innermost + /// ; 0 when unknown. + public static uint RemoteSlideAttributionGuid => _remoteSlideAttributionGuid; + + /// + /// Per-GUID rate limit for the ~30 Hz [remote-slide-tick] line. + /// A resting remote emits at most one line per this interval; any change + /// in the caller-supplied signature (the contact/walkable/airborne/ + /// gravity/steep/moved bit pattern) emits immediately, so every + /// transition is captured at full fidelity. + /// + private const long RemoteSlideTickThrottleMs = 200; + + [ThreadStatic] + private static Dictionary? _remoteSlideTickGate; + + /// + /// Edge-or-throttle admission for . + /// Returns true when the line should be emitted; updates the per-GUID + /// gate as a side effect. Probe-owned state only. + /// + public static bool ShouldEmitRemoteSlideTick(uint guid, int signature) + { + if (!ShouldLogRemoteSlide(guid)) return false; + _remoteSlideTickGate ??= new Dictionary(); + long now = Environment.TickCount64; + if (_remoteSlideTickGate.TryGetValue(guid, out var previous) + && previous.Signature == signature + && now - previous.Ms < RemoteSlideTickThrottleMs) + { + return false; + } + _remoteSlideTickGate[guid] = (now, signature); + return true; + } + + /// + /// One [remote-slide-up] line per accepted remote Position, from + /// the single point BOTH remote arms pass through — ahead of the + /// NoPositionOperation early returns, so a Shape-A slide (which + /// acdream answers by writing nothing) still produces a line. + /// Caller MUST guard with . + /// + public static void LogRemoteSlideUp( + uint guid, + bool wireGrounded, + Vector3? wireVelocity, + string disposition, + float? playerDistance, + float bodyToTarget, + float bodySnapThreshold, + bool willBeDrTicked, + // Read at packet ENTRY. The player-remote arm stamps + // LastServerPosTime between here and the routing call, so the value + // ApplyInterpolate actually tests can differ — the + // [remote-slide-snap] producer=ap87-4m line reports that one. Hence + // the distinct firstUpAtEntry= field name. + bool firstUp, + bool airborne, + bool contact, + bool onWalkable, + bool gravity, + Vector3 bodyVelocity, + bool contactPlaneValid, + float contactPlaneNormalZ, + Vector3 wirePosition, + Vector3 bodyPosition, + int interpQueueDepth, + int interpFailCount) + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + string wireVel = wireVelocity is { } wv + ? string.Format(ci, "({0:F3},{1:F3},{2:F3})", wv.X, wv.Y, wv.Z) + : "null"; + string playerDist = playerDistance is { } pd + ? pd.ToString("F2", ci) + : "n/a"; + Console.WriteLine(string.Format(ci, + "[remote-slide-up] guid=0x{0:X8} t={1} wireGrounded={2} wireVel={3} " + + "disp={4} playerDist={5} bodyToTarget={6:F3} snapThreshold={7:F3} " + + "willBeDrTicked={8} firstUpAtEntry={9} airborne={10} contact={11} " + + "onWalkable={12} gravity={13} bodyVel=({14:F3},{15:F3},{16:F3}) " + + "cpValid={17} cpNz={18:F4} floorZ={19:F4} steep={20} " + + "wirePos=({21:F3},{22:F3},{23:F3}) bodyPos=({24:F3},{25:F3},{26:F3}) " + + "queueDepth={27} failCount={28}", + guid, Environment.TickCount64, wireGrounded, wireVel, + disposition, playerDist, bodyToTarget, bodySnapThreshold, + willBeDrTicked, firstUp, airborne, contact, + onWalkable, gravity, + bodyVelocity.X, bodyVelocity.Y, bodyVelocity.Z, + contactPlaneValid, contactPlaneNormalZ, PhysicsGlobals.FloorZ, + contactPlaneValid && contactPlaneNormalZ < PhysicsGlobals.FloorZ, + wirePosition.X, wirePosition.Y, wirePosition.Z, + bodyPosition.X, bodyPosition.Y, bodyPosition.Z, + interpQueueDepth, interpFailCount)); + } + + /// + /// One [remote-slide-vec] line per accepted remote 0xF74E + /// VectorUpdate. NOT ESTABLISHED #4 asks whether ACE relays one at all + /// during a slide — the ABSENCE of these lines across a captured slide + /// window is the answer, which is why this sits on the committed path + /// rather than inside the Velocity.Z > 0.5f airborne branch. + /// Caller MUST guard with . + /// + public static void LogRemoteSlideVector( + uint guid, + Vector3 wireVelocity, + Vector3 wireOmega, + bool willMarkAirborne, + bool airborneBefore, + bool contact, + bool onWalkable, + bool gravity, + Vector3 bodyVelocity, + bool contactPlaneValid, + float contactPlaneNormalZ) + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[remote-slide-vec] guid=0x{0:X8} t={1} " + + "wireVel=({2:F3},{3:F3},{4:F3}) wireOmega=({5:F3},{6:F3},{7:F3}) " + + "willMarkAirborne={8} airborneBefore={9} contact={10} " + + "onWalkable={11} gravity={12} bodyVel=({13:F3},{14:F3},{15:F3}) " + + "cpValid={16} cpNz={17:F4} floorZ={18:F4} steep={19}", + guid, Environment.TickCount64, + wireVelocity.X, wireVelocity.Y, wireVelocity.Z, + wireOmega.X, wireOmega.Y, wireOmega.Z, + willMarkAirborne, airborneBefore, contact, + onWalkable, gravity, + bodyVelocity.X, bodyVelocity.Y, bodyVelocity.Z, + contactPlaneValid, contactPlaneNormalZ, PhysicsGlobals.FloorZ, + contactPlaneValid && contactPlaneNormalZ < PhysicsGlobals.FloorZ)); + } + + /// + /// Blip producer Candidate 1 — AP-87's bodyToTarget > 4 m + /// body snap (RuntimeRemoteSteadyStatePosition.ApplyInterpolate). + /// Tagged producer=ap87-4m. Caller MUST guard with + /// . + /// + public static void LogRemoteSlideBodySnap( + uint guid, + bool firstUp, + bool willBeDrTicked, + float bodyToTarget, + float threshold, + Vector3 bodyPosition, + Vector3 targetPosition, + int interpQueueDepth, + int interpFailCount) + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[remote-slide-snap] producer=ap87-4m guid=0x{0:X8} t={1} " + + "firstUp={2} willBeDrTicked={3} bodyToTarget={4:F3} threshold={5:F3} " + + "body=({6:F3},{7:F3},{8:F3}) target=({9:F3},{10:F3},{11:F3}) " + + "queueDepth={12} failCount={13}", + guid, Environment.TickCount64, + firstUp, willBeDrTicked, bodyToTarget, threshold, + bodyPosition.X, bodyPosition.Y, bodyPosition.Z, + targetPosition.X, targetPosition.Y, targetPosition.Z, + interpQueueDepth, interpFailCount)); + } + + /// + /// The non-blip outcome of the same seam: the packet fed the queue. Its + /// presence is what tells Shape B (queue fed, so Candidate 2 can arm) + /// apart from Shape A (queue never fed, so only Candidate 1 can fire). + /// Caller MUST guard with . + /// + public static void LogRemoteSlideEnqueue( + uint guid, + float bodyToTarget, + Vector3 targetPosition, + int interpQueueDepth, + int interpFailCount) + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[remote-slide-enq] guid=0x{0:X8} t={1} bodyToTarget={2:F3} " + + "target=({3:F3},{4:F3},{5:F3}) queueDepth={6} failCount={7}", + guid, Environment.TickCount64, bodyToTarget, + targetPosition.X, targetPosition.Y, targetPosition.Z, + interpQueueDepth, interpFailCount)); + } + + /// + /// Blip producer Candidate 2 — the retail-faithful + /// node_fail_counter > 3 snap-to-tail inside + /// (retail + /// InterpolationManager::UseTime @0x00555f20). Tagged + /// producer=interp-stall. This line is OBSERVATION ONLY: the code + /// it reports on is a correct port firing correctly on a body frozen + /// upstream, and the diagnosis explicitly rules it out of scope for any + /// fix. Self-guarded on so the + /// snap site pays one bool read when off; GUID comes from + /// . + /// + public static void LogRemoteSlideStallSnap( + int failCount, + int threshold, + int queueDepth, + Vector3 bodyPosition, + Vector3 tailPosition, + float distanceToHead) + { + uint guid = _remoteSlideAttributionGuid; + if (!ShouldLogRemoteSlide(guid)) return; + Vector3 tailDelta = tailPosition - bodyPosition; + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + "[remote-slide-snap] producer=interp-stall guid=0x{0:X8} t={1} " + + "failCount={2} threshold={3} queueDepth={4} " + + "body=({5:F3},{6:F3},{7:F3}) tail=({8:F3},{9:F3},{10:F3}) " + + "tailDelta=({11:F3},{12:F3},{13:F3}) tailDeltaLen={14:F3} " + + "distToHead={15:F3}", + guid, Environment.TickCount64, + failCount, threshold, queueDepth, + bodyPosition.X, bodyPosition.Y, bodyPosition.Z, + tailPosition.X, tailPosition.Y, tailPosition.Z, + tailDelta.X, tailDelta.Y, tailDelta.Z, tailDelta.Length(), + distanceToHead)); + } + + /// + /// One [remote-slide-tick] line per admitted remote physics tick + /// (see for the edge-or-throttle + /// rule). Confirms LIVE what the diagnosis asserts from source. + /// + /// + /// The first three parameters were written against the PRE-FIX code and + /// their meaning changed with it; they keep their C# names because the + /// diagnosis doc quotes them, but they are emitted under different LOG + /// keys (see the format string). and + /// once meant "the per-tick + /// TransientState |= Contact | OnWalkable force flipped a bit that + /// was clear" (Link 1); that force is deleted, and they now report the + /// INVERSE fact — the body entered this tick WITHOUT that transient — and + /// are logged as entryNoContact=/entryNoWalkable=. + /// once named the vector the + /// per-tick Body.Velocity = Zero discarded (Link 2); nothing + /// discards it now, so it is simply the velocity the tick started with. + /// + /// + /// + /// The rs* fields are the sweep's own retail classification, which + /// pre-fix the visible tick never committed (Link 4) and post-fix must + /// agree with the contact=/onWalkable= columns beside them. + /// vs floorZ settles NOT + /// ESTABLISHED #2. + /// + /// Caller MUST guard with . + /// + public static void LogRemoteSlideTick( + uint guid, + bool airborne, + bool forcedContact, + bool forcedWalkable, + Vector3 velocityBeforeZero, + bool resolved, + bool resolveInContact, + bool resolveOnWalkable, + bool resolveIsOnGround, + bool resolveContactPlaneValid, + float resolveContactPlaneNormalZ, + bool bodyContactPlaneValid, + float bodyContactPlaneNormalZ, + bool contact, + bool onWalkable, + bool gravity, + Vector3 velocity, + Vector3 acceleration, + Vector3 preIntegratePosition, + Vector3 postIntegratePosition, + Vector3 resolvedPosition) + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + Console.WriteLine(string.Format(ci, + // n1 (2026-08-04): the two keys below USED to read + // "forcedContact/forcedWalkable" and meant "the deleted per-tick + // force flipped a clear bit". The force is gone; the same + // expressions now mean the body ENTERED the tick WITHOUT that + // transient — the exact inverse of what the old name implied. The + // log keys are renamed so a post-fix capture cannot be misread + // against a pre-fix one; the C# parameter names are unchanged + // because the diagnosis doc quotes them. + "[remote-slide-tick] guid=0x{0:X8} t={1} airborne={2} " + + "entryNoContact={3} entryNoWalkable={4} " + + "velBeforeZero=({5:F3},{6:F3},{7:F3}) resolved={8} " + + "rsInContact={9} rsOnWalkable={10} rsIsOnGround={11} " + + "rsCpValid={12} rsCpNz={13:F4} " + + "bodyCpValid={14} bodyCpNz={15:F4} floorZ={16:F4} steep={17} " + + "contact={18} onWalkable={19} gravity={20} " + + "vel=({21:F3},{22:F3},{23:F3}) accel=({24:F3},{25:F3},{26:F3}) " + + "pre=({27:F3},{28:F3},{29:F3}) post=({30:F3},{31:F3},{32:F3}) " + + "out=({33:F3},{34:F3},{35:F3}) moved={36:F4}", + guid, Environment.TickCount64, airborne, + forcedContact, forcedWalkable, + velocityBeforeZero.X, velocityBeforeZero.Y, velocityBeforeZero.Z, + resolved, + resolveInContact, resolveOnWalkable, resolveIsOnGround, + resolveContactPlaneValid, resolveContactPlaneNormalZ, + bodyContactPlaneValid, bodyContactPlaneNormalZ, PhysicsGlobals.FloorZ, + bodyContactPlaneValid && bodyContactPlaneNormalZ < PhysicsGlobals.FloorZ, + contact, onWalkable, gravity, + velocity.X, velocity.Y, velocity.Z, + acceleration.X, acceleration.Y, acceleration.Z, + preIntegratePosition.X, preIntegratePosition.Y, preIntegratePosition.Z, + postIntegratePosition.X, postIntegratePosition.Y, postIntegratePosition.Z, + resolvedPosition.X, resolvedPosition.Y, resolvedPosition.Z, + Vector3.Distance(preIntegratePosition, resolvedPosition))); + } + public static void LogCellSetBuild( uint seedCellId, System.Numerics.Vector3 sphereCenter, @@ -762,6 +1254,10 @@ public static class PhysicsDiagnostics ProbeStepWalkEnabled = false; ProbeTeleportEnabled = false; ProbeRemoteLandingEnabled = false; + ProbeRemoteSlideEnabled = false; + ProbeRemoteSlideGuids = new System.Collections.Generic.HashSet(); + _remoteSlideAttributionGuid = 0; + _remoteSlideTickGate = null; // Side-channel fields LastBspHitPoly = null; diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs index 1b4d704e..1bf6e6fe 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs @@ -104,6 +104,13 @@ internal sealed class RuntimeRemotePhysicsUpdater return false; } uint serverGuid = record.ServerGuid; + // Bug B (2026-08-04): stamp the GUID for any [remote-slide-*] line + // emitted from inside this remote's synchronous tick — in particular + // blip producer Candidate 2, which fires deep inside + // InterpolationManager and has no GUID of its own. TEMPORARY — strip + // with the ACDREAM_PROBE_REMOTE_SLIDE family. + AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution( + serverGuid); uint localEntityId = record.LocalEntityId ?? throw new InvalidOperationException( $"Runtime entity 0x{serverGuid:X8}/{record.Incarnation} has no local identity."); @@ -126,48 +133,58 @@ internal sealed class RuntimeRemotePhysicsUpdater // Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 scales // the CSequence root displacement by m_scale only while the body - // is OnWalkable; otherwise it clears the displacement. Grounded - // remotes below are explicitly OnWalkable and airborne remotes use - // their authoritative velocity/gravity arc, so this is the same - // branch expressed through our retained runtime state. - System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne - ? rootMotionLocalFrame.Origin * objectScale - : System.Numerics.Vector3.Zero; + // carries ON_WALKABLE_TS (`if ((transient_state & 2) == 0)` at + // 0x00512CA1 multiplies the accumulated root frame by 0f, the else + // arm by m_scale). Bug B (2026-08-04): read the transient the + // sweep committed, never a separately tracked client bool — the + // two disagree exactly on a steep contact, which is the surface + // this whole fix is about. + bool bodyOnWalkableAtTickStart = rm.Body.OnWalkable; + System.Numerics.Vector3 scaledRootMotionLocalOrigin = + bodyOnWalkableAtTickStart + ? rootMotionLocalFrame.Origin * objectScale + : System.Numerics.Vector3.Zero; - // Step 1: re-apply current motion commands → body.Velocity. - // Forces OnWalkable + Contact so the gate in apply_current_movement - // always succeeds (remotes are server-authoritative; we don't - // simulate airborne physics for them). + // Bug B (2026-08-04) capture for the [remote-slide-tick] line + // below. These USED to record whether the deleted per-tick + // `TransientState |= Contact | OnWalkable` force actually flipped a + // clear bit. The force is gone (see the block comment below), so + // they now simply report the transient state the body ENTERED this + // tick with — the INVERSE of what "forced" implied. The C# names + // are kept because the diagnosis doc quotes them, but the LOG keys + // were renamed to `entryNoContact=`/`entryNoWalkable=` so a + // post-fix capture cannot be read against a pre-fix one; grep the + // new keys. + // TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family. + bool slideForcedContact = !rm.Body.InContact; + bool slideForcedWalkable = !rm.Body.OnWalkable; + System.Numerics.Vector3 slideVelocityBeforeZero = rm.Body.Velocity; + + // retail CPhysicsObj::update_object 0x00515D10 -> set_active(1) + // @0x00515DC2. ACTIVE is the only transient this tick may assert + // on its own; CONTACT and ON_WALKABLE belong to + // SetPositionInternal (0x00515330) and are committed from the + // sweep's contact plane below. // - // K-fix9 (2026-04-26): SKIP this when the remote is airborne. - // Otherwise the force-OnWalkable + apply_current_movement - // path stomps the +Z velocity we set in OnLiveVectorUpdated, - // and gravity never gets to integrate the arc. The airborne - // body keeps the launch velocity from the VectorUpdate; - // UpdatePhysicsInternal below applies gravity each tick; - // the next UpdatePosition snaps to the new ground location - // and re-grounds. + // Bug B (2026-08-04): the deleted lines were + // if (!rm.Airborne) + // rm.Body.TransientState |= Contact | OnWalkable | Active; + // rm.Body.Velocity = Vector3.Zero; + // — a per-tick FORGE of both retail transients plus a discard of + // the authoritative velocity ACE delivered. On a 52.4-degree roof + // the sweep correctly reported "contact, not walkable" and this + // overruled it every tick, so `calc_acceleration` saw + // Contact && OnWalkable and returned zero acceleration, friction + // never engaged, and the body could not move at all. Retail has no + // such write: CONTACT comes from `contact_plane_valid` + // (0x00515430) and ON_WALKABLE from `contact_plane.N.z >= floor_z` + // (0x00515465-0x0051548E), and `MoveOrTeleport` 0x00516330 never + // touches the wire velocity vector for a remote at all. + rm.Body.TransientState |= + AcDream.Core.Physics.TransientStateFlags.Active; + if (!rm.Airborne) { - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable - | AcDream.Core.Physics.TransientStateFlags.Active; - - // #184 (2026-07-07): a grounded remote carries NO translation - // velocity. Its per-tick movement is the interp CATCH-UP toward - // the MoveOrTeleport-queued server waypoint (computed at the - // sticky-compose site below), which the KEPT ResolveWithTransition - // sweep de-overlaps against neighbours — and the resolved position - // is written back into the SHADOW (below) so the de-overlap - // persists and neighbours collide against the resolved body, not - // the raw server pos. This REPLACES the old synth-velocity model - // (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity): - // retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity - // leg — a remote translates by adjust_offset and the UP is a gentle - // target. As of #184 Slice 2b this grounded model is the SINGLE - // remote path (players + NPCs) — retail has no fork. - rm.Body.Velocity = System.Numerics.Vector3.Zero; - // Stale server-velocity → stop the locomotion CYCLE (the legs). // ANIM ONLY — translation is the catch-up. Kept verbatim (same // !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch @@ -200,12 +217,6 @@ internal sealed class RuntimeRemotePhysicsUpdater // this per-node dispatch + the funnel. The #170-deleted per-frame // apply_current_movement is NOT reintroduced. } - else - { - // Airborne — keep Active flag (so UpdatePhysicsInternal - // doesn't early-return) but DON'T set Contact / OnWalkable. - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; - } // Step 2: CSequence's complete Frame carries motion-table omega through // the same compose as root translation. PhysicsBody.Omega remains @@ -242,9 +253,15 @@ internal sealed class RuntimeRemotePhysicsUpdater // Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE // dichotomy), so a stuck monster still steers via #171. // • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates - // from velocity + gravity, unchanged). - // Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal - // adds no translation on top of the catch-up — no double-move. + // from velocity + gravity, unchanged). Retail expresses that + // gate as `transient_state & 1` (CONTACT_TS) inside + // InterpolationManager::adjust_offset @0x00555D52, which is the + // `inContact:` argument below; before Bug B's fix the deleted + // per-tick force made that argument permanently true. + // Bug B (2026-08-04): the body's own velocity is no longer discarded + // each tick, so UpdatePhysicsInternal genuinely integrates whatever + // the sweep, gravity, and the authoritative wire vector left on it — + // that integration is what a steep-contact slide IS. if (rm.Host is { } npcHost) { AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta = @@ -252,7 +269,13 @@ internal sealed class RuntimeRemotePhysicsUpdater pmDelta.Origin = scaledRootMotionLocalOrigin; pmDelta.Orientation = rootMotionLocalFrame.Orientation; float maxSpeedNpc = rm.Motion.GetAdjustedMaxSpeed(); - System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne + // AD-10 terrain-only slope projection. Bug B (2026-08-04): + // gated on the committed ON_WALKABLE transient, the same fact + // retail root-frame scaling reads (0x00512CA1), instead of the + // client Airborne bool. A body resting on a NON-walkable steep + // contact must not have its root motion projected onto a + // terrain plane it is not standing on. + System.Numerics.Vector3? terrainNormalNpc = bodyOnWalkableAtTickStart ? _physics.Engine.SampleTerrainNormal( rm.Body.Position.X, rm.Body.Position.Y) @@ -289,7 +312,13 @@ internal sealed class RuntimeRemotePhysicsUpdater pmDelta.Origin = scaledRootMotionLocalOrigin; pmDelta.Orientation = rootMotionLocalFrame.Orientation; float maxSpeedNpc = rm.Motion.GetAdjustedMaxSpeed(); - System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne + // AD-10 terrain-only slope projection. Bug B (2026-08-04): + // gated on the committed ON_WALKABLE transient, the same fact + // retail root-frame scaling reads (0x00512CA1), instead of the + // client Airborne bool. A body resting on a NON-walkable steep + // contact must not have its root motion projected onto a + // terrain plane it is not standing on. + System.Numerics.Vector3? terrainNormalNpc = bodyOnWalkableAtTickStart ? _physics.Engine.SampleTerrainNormal( rm.Body.Position.X, rm.Body.Position.Y) @@ -378,12 +407,16 @@ internal sealed class RuntimeRemotePhysicsUpdater // deR/deH two-scalar reconstruction above. sphereList: sphereList, sphereScale: sphereScale, - // K-fix9 (2026-04-26): mirror the K-fix7 gate — - // airborne remotes must NOT pre-seed the - // ContactPlane, otherwise AdjustOffset's snap-to-plane - // branch zeroes the +Z offset every step (same bug - // we hit on the local jump). - isOnGround: !rm.Airborne, + // With a body present this argument no longer seeds + // transition contact at all (retail check_contact + // 0x0050F5B0 owns that, see PhysicsEngine); it only decides + // whether the retained walkable polygon is handed to the + // SpherePath. Bug B (2026-08-04): read the committed + // ON_WALKABLE transient, exactly like the local player + // (`isOnGround: _body.OnWalkable`) and TickHidden + // (`isOnGround: previousOnWalkable`), instead of the client + // Airborne bool. + isOnGround: previousOnWalkable, body: rm.Body, // persist ContactPlane across frames for slope tracking // Retail default physics state includes EdgeSlide; remote DR // should exercise the same edge/cliff branch as local movement. @@ -446,137 +479,321 @@ internal sealed class RuntimeRemotePhysicsUpdater // to actually-moving remotes — the perf risk the review flagged for // a packed town. (In-place shadow-move + cell-relink-on-change is a // further optimization if profiling still shows churn.) - // AD-25 (2026-07-30): retail CPhysicsObj::handle_all_collisions - // (0x00514780, pc:282647) runs UNCONDITIONALLY after EVERY - // SetPositionInternal — remote objects included; a - // VectorUpdate-launched jump arc is ordinary object physics in - // retail. #173 (2026-07-05) first mirrored the local player's - // reflect math here by hand, but with a narrower gate than - // retail's: `shouldReflect = !(prevOnWalkable && nowOnWalkable - // && !sledding)` collapses to the two ad-hoc branches this - // block used to hand-roll, and got BOTH wrong — the sledding - // branch suppressed the bounce exactly when retail's - // `!sledding` term forces it UNCONDITIONALLY, and the - // non-sledding branch only reflected airborne→airborne where - // retail reflects on every transition except grounded→grounded. - // PhysicsObjUpdate.HandleAllCollisions is the same verbatim - // port the local player and every ordinary body already use - // (PhysicsObjUpdate.CommitSetPositionTransition); call it - // directly instead of re-deriving the gate. It already - // no-ops the reflect step when collisionNormalValid is false, - // but — unlike the old wrapper this replaces — still runs the - // fsf>1 unconditional velocity-zero "bleed" regardless of - // whether this tick found a collision normal, matching - // retail's own unconditional call site. - AcDream.Core.Physics.PhysicsObjUpdate.HandleAllCollisions( - rm.Body, - resolveResult.CollisionNormalValid, - resolveResult.CollisionNormal, - previousContact, - previousOnWalkable, - resolveResult.IsOnGround); - - // K-fix15 (2026-04-26): post-resolve landing - // detection for airborne remotes. Mirrors - // PlayerMovementController's local-player landing - // path: when the resolver says we're on ground AND - // velocity is no longer pointing up, transition - // back to grounded — clear Airborne, restore - // Contact + OnWalkable, remove Gravity, zero any - // residual downward velocity, and trigger - // HitGround so the sequencer can swap from - // Falling → idle/locomotion. Without this, an - // airborne remote falls through the floor (gravity - // keeps building Velocity.Z negative until the - // sphere-sweep clamps each frame, but Airborne - // stays true forever). - if (rm.Airborne - && resolveResult.IsOnGround - && rm.Body.Velocity.Z <= 0f) + // ── SetPositionInternal commit (Bug B, 2026-08-04) ─────────── + // This block REPLACES a bare `HandleAllCollisions(..., + // resolveResult.IsOnGround)` call. That call was the TAIL of + // retail SetPositionInternal (0x00515330) without its PREFIX: + // the sweep's own `InContact` / `OnWalkable` — the exact retail + // classification the engine already computed — were never + // committed to the body, and the ground edge was decided from + // `resolveResult.IsOnGround`, which is `inContact || …` + // (PhysicsEngine) and is therefore TRUE on a steep contact. + // A remote that touched a 52.4-degree roof was consequently + // declared landed, forced walkable, and stripped of gravity. + // + // Retail order (0x00515430 → 0x0051548E → 0x005154FE): + // CONTACT_TS <- collision_info.contact_plane_valid + // calc_acceleration + // ON_WALKABLE_TS <- contact_plane.N.z >= floor_z, via + // set_on_walkable @0x00511310, which is + // the SOLE source of + // MovementManager::HitGround / + // ::LeaveGround — no ownership, player, or + // creature gate anywhere in it + // calc_acceleration + // handle_all_collisions + // + // That is PhysicsObjUpdate.CommitSetPositionTransition's + // sequence MINUS its velocity-authority check: the helper also + // honours an `isVelocityCurrent` delegate and skips + // handle_all_collisions when a newer Vector/Movement packet + // installed a velocity from inside the ground-edge callback + // (PhysicsObjUpdate.cs:101-102). That check is inert here — + // this site would pass it as null (the helper's default), the + // same as the TickHidden call below, because a per-quantum + // simulation step is not a packet apply and opens no window in + // which a competing velocity authority could land: the only + // callbacks between the contact prefix and + // handle_all_collisions are HitGround/LeaveGround and the + // ownership re-check. The packet-driven placement paths are + // the ones that need it: `RemoteTeleportPlacement.Apply` is + // the only caller that passes the delegate, and + // `RuntimeSetPositionState`'s canonical commit makes the same + // check inline around its own `HandleAllCollisions`. + // + // It is spelled out through its own public sub-steps + // (CommitSetPositionContactPrefix / the ground edge / + // CommitSetPositionPostGround / HandleAllCollisions — the seam + // whose doc comment exists for precisely this) for two reasons: + // the Bug A landing probes must bracket the exact HitGround + // call, and this per-remote per-quantum path must not allocate + // an `isCurrent` closure. + // + // The whole commit is gated on `Ok && candidateMoved`, matching + // PlayerMovementController and retail UpdateObjectInternal + // (pc:283657): a failed transition is discarded whole and a + // zero-move frame never re-derives contact. + bool candidateMoved = postIntegratePos != preIntegratePos; + if (resolveResult.Ok && candidateMoved) { - rm.Airborne = false; - // #184 (2026-07-07): clear the interp queue on landing (mirrors - // the player-remote landing). Airborne UPs hard-snap and never - // Enqueue, so any pre-jump waypoints are stale; without this the - // first grounded catch-up after touchdown chases them backward. - rm.Interp.Clear(); - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable; - rm.Body.Velocity = new System.Numerics.Vector3( - rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); - // #161: HitGround MUST run with the Gravity state - // bit still set — CMotionInterp::HitGround - // (0x00528ac0) gates on state&0x400 (retail never - // clears GRAVITY on landing; it's a persistent - // object property). Clearing it first made this - // re-apply a silent no-op, which is why the - // falling pose never exited. The re-apply - // dispatches the PRESERVED pre-fall forward - // command through the funnel → the motion table - // plays the Falling→X landing link. (The old - // K-fix17 forced SetCycle is deleted: it read the - // then-clobbered InterpretedState.ForwardCommand - // — 0x40000015 — and re-set the very Falling - // cycle it meant to clear.) - // R4-V5 (closes the V4 wiring-contract gap the - // adversarial review caught): retail order — - // minterp first, then moveto (MovementManager:: - // HitGround 0x00524300, §2d — the R5-V5 facade - // relay). Re-arms a moveto suspended by the - // airborne UseTime contact gate; without it a - // chasing NPC that lands stalls until ACE's - // ~1 Hz re-emit. - ulong landingStateAuthorityVersion = - record.StateAuthorityVersion; + bool finalOnWalkable = AcDream.Core.Physics.PhysicsObjUpdate + .CommitSetPositionContactPrefix( + rm.Body, + resolveResult.InContact, + resolveResult.OnWalkable, + previousOnWalkable); - // Bug A investigation (2026-08-04, docs/ISSUES.md #32): - // capture the exact state HitGround is about to act on — - // see PhysicsDiagnostics.LogRemoteLanding for the field - // list and PhysicsDiagnostics.ProbeRemoteLandingEnabled - // for the discriminator table. TEMPORARY — strip once - // the live-test run has landed. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) + if (!previousOnWalkable && finalOnWalkable) { - bool gravitySetForProbe = rm.Body.HasGravity; - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLanding( - site: "per-tick", - guid: serverGuid, - airborneBefore: true, - gravitySet: gravitySetForProbe, - contact: rm.Body.InContact, - onWalkable: rm.Body.OnWalkable, - hasDefaultSink: rm.Motion.DefaultSink is not null, - resolveIsOnGround: resolveResult.IsOnGround, - sequencerStyle: sequencer?.CurrentStyle ?? 0, - sequencerMotion: sequencer?.CurrentMotion ?? 0); - if (!gravitySetForProbe) + // Bug A investigation (2026-08-04, docs/ISSUES.md #32): + // capture the exact state HitGround is about to act on — + // see PhysicsDiagnostics.LogRemoteLanding for the field + // list and PhysicsDiagnostics.ProbeRemoteLandingEnabled + // for the discriminator table. TEMPORARY — strip once + // the live-test run has landed. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) { - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp( - "per-tick", serverGuid); + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLanding( + site: "per-tick", + guid: serverGuid, + airborneBefore: true, + gravitySet: rm.Body.HasGravity, + contact: rm.Body.InContact, + onWalkable: rm.Body.OnWalkable, + hasDefaultSink: rm.Motion.DefaultSink is not null, + resolveIsOnGround: resolveResult.IsOnGround, + sequencerStyle: sequencer?.CurrentStyle ?? 0, + sequencerMotion: sequencer?.CurrentMotion ?? 0); + if (!rm.Body.HasGravity) + { + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp( + "per-tick", serverGuid); + } + AcDream.Core.Physics.PhysicsDiagnostics + .BeginRemoteLandingDispatchCapture(); + } + + // #161: HitGround MUST run with the Gravity state bit + // still set — CMotionInterp::HitGround (0x00528AC0) + // gates on state & 0x400. Bug B deleted the clear that + // used to follow this call: retail NEVER toggles + // GRAVITY_PS on a ground edge (`set_state` @0x00514DD0 + // post-processes only lighting/nodraw/hidden), it gates + // gravity ACCELERATION on the CONTACT/ON_WALKABLE + // transients inside calc_acceleration @0x00510950. + // R4-V5: retail order is minterp then moveto + // (MovementManager::HitGround 0x00524300). + rm.Movement.HitGround(); + + // Bug A investigation (2026-08-04) — the OUTCOME half of + // the probe above, emitted before the ownership re-check + // below can return so the two lines always pair. See + // PhysicsDiagnostics.LogRemoteLandingAfter and + // docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md + // §6.1 for the three-way decision table. TEMPORARY. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) + { + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter( + site: "per-tick", + guid: serverGuid, + hitGroundInvoked: true, + sequencerStyle: sequencer?.CurrentStyle ?? 0, + sequencerMotion: sequencer?.CurrentMotion ?? 0, + forwardCommand: rm.Motion.InterpretedState.ForwardCommand); + } + if (!IsCurrentOwner( + record, + rm, + objectClockEpoch, + externalOwnerValid)) + { + return false; + } + + // #184 (2026-07-07): clear the interp queue on the + // LANDING edge. An airborne remote's Positions hard-snap + // and never Enqueue, so any pre-arc waypoints are stale; + // without this the first grounded catch-up after + // touchdown chases them backward. Bug B kept the + // behaviour and only re-derived the edge — it now hangs + // off the same `set_on_walkable(1)` transition retail + // fires HitGround from, instead of the hand-rolled + // `IsOnGround && Velocity.Z <= 0` test that fired on a + // steep contact too. Register row AP-139. + rm.Interp.Clear(); + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + } + else if (previousOnWalkable && !finalOnWalkable) + { + // set_on_walkable(0) @0x0051133C — + // MovementManager::LeaveGround. A remote that walks off + // a ledge or slides off a walkable lip onto a steep face + // now takes retail's ground-departure edge instead of + // staying nominally grounded forever. + rm.Motion.LeaveGround(); + if (!IsCurrentOwner( + record, + rm, + objectClockEpoch, + externalOwnerValid)) + { + return false; } } - rm.Movement.HitGround(); - if (!IsCurrentOwner( - record, - rm, - objectClockEpoch, - externalOwnerValid)) - { - return false; - } - // DR bookkeeping only (partner of the jump-start - // `State |= Gravity`): stops the per-tick gravity - // integration for the grounded body. - if (record.StateAuthorityVersion - == landingStateAuthorityVersion) - { - rm.Body.State &= - ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; - } + AcDream.Core.Physics.PhysicsObjUpdate + .CommitSetPositionPostGround(rm.Body); - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); + // retail CPhysicsObj::handle_all_collisions (0x00514780, + // pc:282647) @0x005154FE — the same verbatim port the local + // player and every ordinary body use. `nowOnWalkable` is the + // COMMITTED transient, not the contact-derived + // `resolveResult.IsOnGround` the old call passed: on a steep + // contact those disagree, and passing IsOnGround suppressed + // the landing reflect exactly where retail forces it. + AcDream.Core.Physics.PhysicsObjUpdate.HandleAllCollisions( + rm.Body, + resolveResult.CollisionNormalValid, + resolveResult.CollisionNormal, + previousContact, + previousOnWalkable, + rm.Body.OnWalkable); + + // Bug B (2026-08-04): Airborne is DERIVED from the committed + // ON_WALKABLE transient, never latched by a landing test. + // This is the project's ONE definition of the flag — every + // writer spells `!Body.OnWalkable`, and there are FIVE of + // them: `SettleSpawnedRemoteContact` (the spawn-settle + // tail) and `RemoteTeleportPlacement.Apply` in App, + // `RuntimeSetPositionState`'s canonical placement commit, + // and this file's two (here and the `TickHidden` resolve). + // `PlayerMovementController.IsAirborne` computes the same + // predicate for the local player. It stays unchanged here; + // only the fact it is derived FROM has moved, from a + // hand-rolled `IsOnGround` test to the sweep's own + // contact-plane result. + // + // What this flag then GATES is a separate, still-open + // divergence: retail's free-flight predicate for the + // interpolate-vs-snap decision is CONTACT, not walkability + // (`InterpolationManager::adjust_offset` @0x00555D30 gates + // its whole body on `transient_state & 1` @0x00555D52). + // Register row AP-140. + rm.Airborne = !rm.Body.OnWalkable; + } + + // Bug B (2026-08-04) — [remote-slide-tick]. Emitted here, after + // the SetPositionInternal commit above, so it reports the + // sweep's own retail classification (rsInContact / rsOnWalkable + // / rsCpNz) next to what the body now carries. Before the fix + // those two columns disagreed on a steep roof — that + // disagreement WAS the bug — and they must now agree on every + // committed frame. bodyCpNz vs floorZ settles NOT ESTABLISHED + // #2 ("is that roof steep in OUR collision data"). + // + // Rate limit: ShouldEmitRemoteSlideTick emits immediately on + // any change to the signature below (every contact / walkable / + // grounded / airborne / gravity / steep / moved transition is + // captured at full 30 Hz fidelity) and otherwise throttles to + // one line per GUID per 200 ms, so a RESTING remote — the whole + // point of the capture — stays at ~5 lines/s instead of 30. + // TEMPORARY — strip with the ACDREAM_PROBE_REMOTE_SLIDE family. + { + bool slideBodyCpValid = rm.Body.ContactPlaneValid; + float slideBodyCpNz = rm.Body.ContactPlane.Normal.Z; + int slideSignature = + (rm.Airborne ? 1 << 0 : 0) + | (slideForcedContact ? 1 << 1 : 0) + | (slideForcedWalkable ? 1 << 2 : 0) + | (resolveResult.InContact ? 1 << 3 : 0) + | (resolveResult.OnWalkable ? 1 << 4 : 0) + | (resolveResult.IsOnGround ? 1 << 5 : 0) + | (rm.Body.InContact ? 1 << 6 : 0) + | (rm.Body.OnWalkable ? 1 << 7 : 0) + | (rm.Body.HasGravity ? 1 << 8 : 0) + | (slideBodyCpValid ? 1 << 9 : 0) + | (slideBodyCpValid + && slideBodyCpNz + < AcDream.Core.Physics.PhysicsGlobals.FloorZ + ? 1 << 10 : 0) + | (System.Numerics.Vector3.Distance( + preIntegratePos, resolveResult.Position) > 0.01f + ? 1 << 11 : 0); + if (AcDream.Core.Physics.PhysicsDiagnostics + .ShouldEmitRemoteSlideTick(serverGuid, slideSignature)) + { + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideTick( + guid: serverGuid, + airborne: rm.Airborne, + forcedContact: slideForcedContact, + forcedWalkable: slideForcedWalkable, + velocityBeforeZero: slideVelocityBeforeZero, + resolved: true, + resolveInContact: resolveResult.InContact, + resolveOnWalkable: resolveResult.OnWalkable, + resolveIsOnGround: resolveResult.IsOnGround, + resolveContactPlaneValid: resolveResult.InContact, + resolveContactPlaneNormalZ: + resolveResult.ContactPlane.Normal.Z, + bodyContactPlaneValid: slideBodyCpValid, + bodyContactPlaneNormalZ: slideBodyCpNz, + contact: rm.Body.InContact, + onWalkable: rm.Body.OnWalkable, + gravity: rm.Body.HasGravity, + velocity: rm.Body.Velocity, + acceleration: rm.Body.Acceleration, + preIntegratePosition: preIntegratePos, + postIntegratePosition: postIntegratePos, + resolvedPosition: resolveResult.Position); + } + } + + } + else + { + // Bug B (2026-08-04): the sweep was SKIPPED this tick (no + // starting cell, or no landblocks resident). Reported with + // resolved=false and every rs* field default so a silent + // stretch in the log cannot be misread as "the probe is not + // firing". Same edge-or-throttle admission. TEMPORARY — strip + // with the ACDREAM_PROBE_REMOTE_SLIDE family. + bool skipBodyCpValid = rm.Body.ContactPlaneValid; + float skipBodyCpNz = rm.Body.ContactPlane.Normal.Z; + int skipSignature = + (rm.Airborne ? 1 << 0 : 0) + | (slideForcedContact ? 1 << 1 : 0) + | (slideForcedWalkable ? 1 << 2 : 0) + | (rm.Body.InContact ? 1 << 6 : 0) + | (rm.Body.OnWalkable ? 1 << 7 : 0) + | (rm.Body.HasGravity ? 1 << 8 : 0) + | (skipBodyCpValid ? 1 << 9 : 0) + | (1 << 12); + if (AcDream.Core.Physics.PhysicsDiagnostics + .ShouldEmitRemoteSlideTick(serverGuid, skipSignature)) + { + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideTick( + guid: serverGuid, + airborne: rm.Airborne, + forcedContact: slideForcedContact, + forcedWalkable: slideForcedWalkable, + velocityBeforeZero: slideVelocityBeforeZero, + resolved: false, + resolveInContact: false, + resolveOnWalkable: false, + resolveIsOnGround: false, + resolveContactPlaneValid: false, + resolveContactPlaneNormalZ: 0f, + bodyContactPlaneValid: skipBodyCpValid, + bodyContactPlaneNormalZ: skipBodyCpNz, + contact: rm.Body.InContact, + onWalkable: rm.Body.OnWalkable, + gravity: rm.Body.HasGravity, + velocity: rm.Body.Velocity, + acceleration: rm.Body.Acceleration, + preIntegratePosition: preIntegratePos, + postIntegratePosition: postIntegratePos, + resolvedPosition: rm.Body.Position); } } @@ -696,6 +913,13 @@ internal sealed class RuntimeRemotePhysicsUpdater ?? throw new InvalidOperationException( $"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no local identity."); + // Bug B (2026-08-04): hidden remotes run the same ComposeOffset chain, + // so the InterpolationManager stall snap can fire from here too and + // needs the same GUID attribution. TEMPORARY — strip with the + // ACDREAM_PROBE_REMOTE_SLIDE family. + AcDream.Core.Physics.PhysicsDiagnostics.BeginRemoteSlideAttribution( + record.ServerGuid); + System.Numerics.Vector3 preComposePosition = rm.Body.Position; // The part-array contribution is the identity frame while Hidden. diff --git a/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs b/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs index 8e27797e..28fd1030 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs @@ -41,6 +41,15 @@ internal static class RuntimeRemoteSteadyStatePosition /// private const float BodySnapThreshold = 4f; + /// + /// Bug B (2026-08-04): the same constant, exposed read-only so the + /// [remote-slide-up] line can print the threshold its + /// bodyToTarget is about to be compared against instead of the + /// reader having to remember it. TEMPORARY — strip with the + /// ACDREAM_PROBE_REMOTE_SLIDE family. + /// + internal const float DiagnosticBodySnapThreshold = BodySnapThreshold; + internal enum Action : byte { /// AP-87 backstop: the body wasn't already tracking the @@ -130,6 +139,28 @@ internal static class RuntimeRemoteSteadyStatePosition float bodyToTarget = Vector3.Distance(remote.Body.Position, worldPosition); if (firstUp || !willBeDrTicked || bodyToTarget > BodySnapThreshold) { + // Bug B (2026-08-04) blip producer CANDIDATE 1. Emitted BEFORE the + // snap so body/queue state is the pre-snap truth the reader needs. + // docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md §2. + // Pure read; the GUID comes from the attribution latch the routing + // seam stamps. TEMPORARY — strip with the probe family. + if (AcDream.Core.Physics.PhysicsDiagnostics.ShouldLogRemoteSlide( + AcDream.Core.Physics.PhysicsDiagnostics.RemoteSlideAttributionGuid)) + { + (int depth, int failCount) = + remote.Interp.DiagnosticInterpolationState; + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideBodySnap( + guid: AcDream.Core.Physics.PhysicsDiagnostics + .RemoteSlideAttributionGuid, + firstUp: firstUp, + willBeDrTicked: willBeDrTicked, + bodyToTarget: bodyToTarget, + threshold: BodySnapThreshold, + bodyPosition: remote.Body.Position, + targetPosition: worldPosition, + interpQueueDepth: depth, + interpFailCount: failCount); + } remote.Interp.Clear(); remote.Body.Position = worldPosition; remote.Body.Orientation = orientation; @@ -144,6 +175,23 @@ internal static class RuntimeRemoteSteadyStatePosition remote.Body.Orientation); if (immediate is { } close) remote.Body.Orientation = close; + // Bug B (2026-08-04): the NON-blip outcome. Its presence across a + // slide window is what separates Shape B (queue fed, so the + // InterpolationManager stall snap can arm) from Shape A (queue never + // fed at all). TEMPORARY — strip with the probe family. + if (AcDream.Core.Physics.PhysicsDiagnostics.ShouldLogRemoteSlide( + AcDream.Core.Physics.PhysicsDiagnostics.RemoteSlideAttributionGuid)) + { + (int depth, int failCount) = + remote.Interp.DiagnosticInterpolationState; + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteSlideEnqueue( + guid: AcDream.Core.Physics.PhysicsDiagnostics + .RemoteSlideAttributionGuid, + bodyToTarget: bodyToTarget, + targetPosition: worldPosition, + interpQueueDepth: depth, + interpFailCount: failCount); + } return Action.Enqueued; } diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteepContactSlideTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteepContactSlideTests.cs new file mode 100644 index 00000000..9040ac64 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteepContactSlideTests.cs @@ -0,0 +1,518 @@ +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +/// +/// Bug B (2026-08-04) — remote characters froze on steep surfaces instead of +/// sliding. The per-tick remote owner forged retail's two contact transients +/// (Contact | OnWalkable) before every sweep, discarded the +/// authoritative velocity, decided its landing edge from the contact-derived +/// ResolveResult.IsOnGround rather than the plane-derived +/// OnWalkable, and cleared the persistent Gravity state bit. +/// +/// +/// Retail derives all of it: CPhysicsObj::SetPositionInternal +/// (0x00515330) writes CONTACT_TS from +/// collision_info.contact_plane_valid (0x00515430) and then routes +/// ON_WALKABLE_TS through set_on_walkable (0x00511310) purely on +/// contact_plane.N.z < PhysicsGlobals::floor_z +/// (0x00515465-0x0051548E). set_on_walkable is the SOLE source of +/// MovementManager::HitGround/::LeaveGround. Gravity survives a +/// steep contact because calc_acceleration (0x00510950) only +/// zeroes acceleration when CONTACT and ON_WALKABLE are BOTH set, and +/// calc_friction (0x0050EE70) returns at its first line when +/// ON_WALKABLE is clear. +/// +/// +/// +/// Every test here runs the production +/// tick over a synthetic landblock whose terrain is a single constant-gradient +/// ramp, so the contact plane the sweep finds is a real geometric result, not a +/// stubbed value. +/// +/// +public sealed class RuntimeRemoteSteepContactSlideTests +{ + /// + /// Ramp gradient chosen so the terrain-plane normal's Z lands just under + /// retail's walkable limit — 52.4 degrees against a 48.4-degree limit, the + /// same relationship as the live house roof that produced the freeze + /// (measured contact-plane Normal.Z 0.6097 versus FloorZ 0.6642). + /// + private const float SteepGradient = 1.30f; + + /// A gentle ramp that is comfortably walkable. + private const float WalkableGradient = 0.10f; + + [Fact] + public void SteepTerrainProducesANonWalkableContactPlane() + { + using Harness harness = Harness.OnRamp(SteepGradient); + + Assert.True(harness.Remote.Body.ContactPlaneValid); + Assert.InRange( + harness.Remote.Body.ContactPlane.Normal.Z, + 0.55f, + PhysicsGlobals.FloorZ - 0.001f); + } + + /// + /// The landing edge must be the sweep's plane-derived + /// OnWalkable, never IsOnGround (which is + /// inContact || … and is therefore TRUE on a steep contact). + /// + [Fact] + public void SteepContactDoesNotLatchALanding() + { + using Harness harness = Harness.OnRamp(SteepGradient); + harness.Remote.Airborne = true; + int groundEdges = 0; + harness.Remote.Motion.RemoveLinkAnimations = () => groundEdges++; + + harness.Tick(40); + + Assert.True(harness.Remote.Body.InContact); + Assert.False(harness.Remote.Body.OnWalkable); + Assert.Equal(0, groundEdges); + } + + /// + /// Gravity is a persistent object property in retail; nothing on a ground + /// edge may clear it. Before the fix both landing blocks did, which is why + /// calc_acceleration returned zero forever afterwards. + /// + [Fact] + public void GravityPersistsAcrossTicksOnASteepContact() + { + using Harness harness = Harness.OnRamp(SteepGradient); + harness.Remote.Airborne = true; + + harness.Tick(40); + + Assert.True(harness.Remote.Body.HasGravity); + Assert.True(harness.Remote.Body.Acceleration.Z < -1f); + } + + /// + /// The visible consequence: a remote resting on a non-walkable face keeps + /// moving. Before the fix the body reported moved=0.0000 on every + /// tick, forever. + /// + [Fact] + public void SteepContactKeepsTheBodySlidingDownhill() + { + using Harness harness = Harness.OnRamp(SteepGradient); + Vector3 start = harness.Remote.Body.Position; + + harness.Tick(40); + + Vector3 travelled = harness.Remote.Body.Position - start; + Assert.True( + travelled.Length() > 0.25f, + $"expected a slide, body moved {travelled.Length():F4} m"); + Assert.True( + travelled.Z < -0.1f, + $"expected downhill travel, dz = {travelled.Z:F4} m"); + } + + /// + /// The direct statement of "stop forging inputs": with no sweep to derive + /// from — no starting cell, so ResolveWithTransition is skipped + /// entirely — the tick must leave both retail transients exactly as it + /// found them. Retail's only writer is SetPositionInternal + /// (0x00515330), which a skipped transition never reaches. + /// + [Fact] + public void TheTickNeverAssertsContactOrWalkableWithoutASweep() + { + using Harness harness = Harness.OnRamp(WalkableGradient); + harness.Remote.CellId = 0u; + harness.Remote.Body.TransientState &= ~(TransientStateFlags.Contact + | TransientStateFlags.OnWalkable); + harness.Remote.Airborne = false; + + harness.Tick(1); + + Assert.False(harness.Remote.Body.InContact); + Assert.False(harness.Remote.Body.OnWalkable); + } + + /// + /// The tick immediately after a body crossed from walkable ground onto a + /// steep face: it enters carrying last tick's grounded transients and its + /// downhill speed. The tick must NOT re-assert those transients — with + /// Contact | OnWalkable forced, calc_acceleration + /// (0x00510950) returns zero and calc_friction + /// (0x0050EE70) engages, so the body decelerates to a stop on a face + /// retail would keep accelerating it down. + /// + [Fact] + public void AGroundedTickOnASteepFaceReleasesTheBodyInsteadOfPinningIt() + { + using Harness harness = Harness.OnRamp(SteepGradient); + harness.Remote.Body.TransientState |= + TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + harness.Remote.Airborne = false; + harness.Remote.Body.Velocity = + Vector3.Normalize(new Vector3(0f, 1f, -SteepGradient)) * 3f; + Vector3 start = harness.Remote.Body.Position; + + harness.Tick(40); + + Assert.False(harness.Remote.Body.OnWalkable); + float travelled = (harness.Remote.Body.Position - start).Length(); + Assert.True( + travelled > 2f, + $"expected the steep face to release the body, travelled {travelled:F3} m"); + } + + /// + /// Retail's MoveOrTeleport (0x00516330) never reads or writes + /// the wire velocity for a remote; the deleted per-tick + /// Body.Velocity = Zero threw away whatever ACE delivered through + /// 0xF74E as well as everything gravity had accumulated. + /// + [Fact] + public void AuthoritativeVelocityIsNotDiscardedOnAGroundedTick() + { + using Harness harness = Harness.OnRamp(WalkableGradient); + Assert.False(harness.Remote.Airborne); + harness.Remote.Body.Velocity = new Vector3(2.146f, 2.264f, -3.549f); + + harness.Tick(1); + + Assert.NotEqual(Vector3.Zero, harness.Remote.Body.Velocity); + Assert.True( + harness.Remote.Body.Velocity.X > 0.5f, + $"velocity X was {harness.Remote.Body.Velocity.X:F4}"); + } + + /// + /// The committed transients must be the ones the sweep's contact plane + /// implies — Contact from plane validity, OnWalkable from + /// Normal.Z >= floor_z — and never an independently asserted pair. + /// + /// + /// Deliberately NOT stated as the two equalities + /// ContactPlaneValid == InContact and + /// IsWalkableContact(committed plane) == OnWalkable. Neither is an + /// invariant of the production code, and this test asserted both until the + /// 2026-08-04 review: PhysicsEngine.ResolveWithTransition publishes + /// the contact plane whenever the transition returned ok, while the + /// transient commit additionally requires candidateMoved + /// (RuntimeRemotePhysicsUpdater's SetPositionInternal commit, + /// matching retail UpdateObjectInternal pc:283657), so a zero-move + /// frame can legitimately leave the two one tick apart. The same writeback + /// also falls back to LastKnownContactPlane, which keeps + /// ContactPlaneValid true across a contact-FREE frame by design. + /// The old assertions passed only because every body in this fixture moves + /// on every tick. What is asserted instead is what the commit path DOES + /// guarantee — the two implications — plus each fixture's known ramp + /// geometry checked on BOTH sides of retail's walkability comparison, so + /// re-forging Contact | OnWalkable still fails the steep case. + /// + /// + [Fact] + public void CommittedTransientsAgreeWithTheCommittedContactPlane() + { + using Harness steep = Harness.OnRamp(SteepGradient); + steep.Tick(20); + AssertTransientsAreContactPlaneDerived( + steep.Remote.Body, expectWalkable: false); + + using Harness gentle = Harness.OnRamp(WalkableGradient); + gentle.Tick(20); + AssertTransientsAreContactPlaneDerived( + gentle.Remote.Body, expectWalkable: true); + } + + private static void AssertTransientsAreContactPlaneDerived( + PhysicsBody body, + bool expectWalkable) + { + // Unconditional: OnWalkable is only ever written as + // `inContact && onWalkable` + // (PhysicsObjUpdate.CommitSetPositionContactPrefix), so it cannot + // outlive Contact on any frame, committed or not. + Assert.True( + !body.OnWalkable || body.InContact, + "OnWalkable without Contact — the two transients were asserted " + + "independently of the contact plane"); + + // Contact is written from the sweep's contact-plane validity by the + // same resolve that publishes the plane, so a body in contact carries + // a valid plane. The CONVERSE is not guaranteed — see the summary. + Assert.True( + !body.InContact || body.ContactPlaneValid, + "Contact without a valid contact plane — Contact was not " + + "plane-derived"); + + // Both sides of retail's walkability comparison + // (SetPositionInternal 0x00515465-0x0051548E) against this fixture's + // known constant-gradient ramp: the plane the sweep found, and the + // transient that plane drove. + Assert.Equal( + expectWalkable, + body.ContactPlane.Normal.Z >= PhysicsGlobals.FloorZ); + Assert.Equal(expectWalkable, body.OnWalkable); + } + + /// + /// The other half of the edge: a genuine walkable landing must still fire + /// retail's set_on_walkable(1) -> MovementManager::HitGround + /// exactly once and leave the body grounded. + /// + [Fact] + public void WalkableLandingStillLandsAndFiresTheGroundEdgeOnce() + { + using Harness harness = Harness.Airborne(WalkableGradient, height: 3f); + int groundEdges = 0; + harness.Remote.Motion.RemoveLinkAnimations = () => groundEdges++; + + harness.Tick(60); + + Assert.True(harness.Remote.Body.OnWalkable); + Assert.False(harness.Remote.Airborne); + Assert.Equal(1, groundEdges); + } + + /// + /// GRAVITY_PS is set by the retail CPhysicsObj constructor + /// (state 0x400C08 @0x00512508) and thereafter assigned wholesale from the + /// wire by set_description's set_state (0x00514DD0), + /// which post-processes only lighting/nodraw/hidden. No ground edge + /// anywhere in retail toggles it — acdream's two landing blocks did, which + /// is what left a landed remote permanently unable to fall again. + /// + [Fact] + public void WalkableLandingDoesNotClearTheGravityStateBit() + { + using Harness harness = Harness.Airborne(WalkableGradient, height: 3f); + + harness.Tick(60); + + Assert.True(harness.Remote.Body.OnWalkable); + Assert.True(harness.Remote.Body.HasGravity); + } + + private sealed class Harness : IDisposable + { + private const uint LandblockId = 0x0101FFFFu; + + private readonly RuntimeEntityObjectLifetime _lifetime; + private readonly RuntimeEntityRecord _record; + private readonly RuntimeRemotePhysicsUpdater _updater; + + internal RemoteMotion Remote { get; } + + private Harness( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record, + RemoteMotion remote, + RuntimeRemotePhysicsUpdater updater) + { + _lifetime = lifetime; + _record = record; + Remote = remote; + _updater = updater; + } + + /// Body already resting on the ramp, contact established. + internal static Harness OnRamp(float gradient) + { + Harness harness = Create(gradient, heightAboveSurface: 0f); + // Retail gains spawn contact from the first gravity frame; the + // stationary-remote settle (SpawnPlacementSettler, #270) compresses + // it. Use the production seam so the fixture starts from exactly + // the state a live spawn would. + SpawnPlacementSettler.TrySettle( + harness._lifetime.Physics.Engine, + harness.Remote.Body, + harness.Remote.Body.Position, + harness.Remote.CellId, + sphereRadius: 0.48f, + sphereHeight: 1.835f, + ObjectInfoState.EdgeSlide, + harness._record.LocalEntityId!.Value, + harness.Remote.Movement.HitGround, + harness.Remote.Motion.LeaveGround); + harness.Remote.Airborne = !harness.Remote.Body.OnWalkable; + return harness; + } + + /// Body suspended above the ramp with no contact at all. + internal static Harness Airborne(float gradient, float height) + { + Harness harness = Create(gradient, heightAboveSurface: height); + harness.Remote.Body.TransientState &= ~(TransientStateFlags.Contact + | TransientStateFlags.OnWalkable); + harness.Remote.Body.ContactPlaneValid = false; + harness.Remote.Airborne = true; + return harness; + } + + private static Harness Create(float gradient, float heightAboveSurface) + { + var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.Physics.Engine.AddLandblock( + LandblockId, + Ramp(gradient), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + RuntimeEntityRecord record = lifetime.Entities.AddActive(Spawn()); + var body = new PhysicsBody + { + // Retail CPhysicsObj constructor state 0x400C08 @0x00512508 + // (EdgeSlide | Lighting | Gravity | ReportCollisions), which + // ACE also sends for every creature (PhysicsGlobals.DefaultState). + State = PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.EdgeSlide, + InWorld = true, + }; + var remote = new RemoteMotion(body); + lifetime.Entities.SetPhysicsBody(record, body); + lifetime.Entities.SetRemoteMotion(record, remote); + lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + + const float localX = 96f; + const float localY = 96f; + float surfaceZ = Ramp(gradient).SampleZ(localX, localY); + body.Position = new Vector3( + localX, + localY, + surfaceZ + heightAboveSurface); + body.Orientation = Quaternion.Identity; + remote.CellId = TerrainSurface.ComputeOutdoorCellId( + LandblockId, + localX, + localY); + remote.LastServerPos = body.Position; + remote.LastServerPosTime = 1.0; + + return new Harness( + lifetime, + record, + remote, + new RuntimeRemotePhysicsUpdater(lifetime.Physics)); + } + + internal void Tick(int count, float dt = 1f / 30f) + { + var frame = new MotionDeltaFrame(); + for (int i = 0; i < count; i++) + { + frame.Reset(); + _updater.Tick( + _record, + Remote, + objectScale: 1f, + sequencer: null, + dt, + _record.ObjectClockEpoch, + frame, + radius: 0.48f, + height: 1.835f, + liveCenterX: 1, + liveCenterY: 1); + } + } + + /// + /// A constant-gradient ramp climbing along +Y. The heightmap byte at + /// (x, y) indexes a table whose entries rise linearly, so every cell of + /// the landblock has the same plane normal and the sampled contact + /// plane is exactly normalize((0, -gradient, 1)). + /// + private static TerrainSurface Ramp(float gradient) + { + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i * gradient * TerrainSurface.CellSize; + + var heights = new byte[81]; + for (int x = 0; x < 9; x++) + for (int y = 0; y < 9; y++) + heights[x * 9 + y] = (byte)(8 - y); + + return new TerrainSurface(heights, heightTable); + } + + private static WorldSession.EntitySpawn Spawn() + { + var position = new CreateObject.ServerPosition( + LandblockId, + 96f, + 96f, + 0f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + const uint rawState = (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.EdgeSlide); + var physics = new PhysicsSpawnData( + RawState: rawState, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + 0x70000101u, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "bug-b-fixture", + null, + null, + 0x09000001u, + PhysicsState: rawState, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + public void Dispose() => _lifetime.Dispose(); + } +}