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

559 lines
30 KiB
Markdown

# 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`.