acdream/docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md
Erik 204d0ae047 fix(physics): remote bodies slide on steep faces instead of freezing (#32)
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 <noreply@anthropic.com>
2026-08-04 10:21:16 +02:00

30 KiB

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 ticksrc/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 cyclefullMotion 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 funnelRemoteInboundMotionDispatcher.Apply (src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs:112) → MotionInterpreter.MoveToInterpretedState (src/AcDream.Core/Physics/MotionInterpreter.cs:2741-2788) → ApplyInterpretedMovement (:2764).
  • (b) the local ground edgesMotionInterpreter.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-2053CommitSetPositionTransition(..., 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-796CommitSetPositionTransition(..., 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:

// 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-192RemoteServerControlledVelocityCycle.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.