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>
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 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) andHitGround(:2426-2444), each ending inapply_current_movement(:1460-1473) →ApplyCurrentMovementInterpreted(:1547-1563) → the sameApplyInterpretedMovement(:2842-2903) through the sameDefaultSink.
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.5VectorUpdate (walk off a ledge, step off a porch, a dropped/reordered0xF74E). In that caseLeaveGroundalso 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_movereturnstrueearly (MotionInterpreter.cs:2142-2143) andHitGroundno-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:
// 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:
- Delete the unconditional
TransientState |= Contact | OnWalkableat:152-154(the!rm.Airborneper-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. - Delete the twin landing block at
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2019-2117'sTransientState |= Contact | OnWalkable+HitGround+ Gravity-clear, and therm.Airborne = true+ contact-clear + Gravity-set at:1265-1273.LeaveGroundat:1290goes with them — retail fires it fromset_on_walkable, not from a0xF74Ehandler. The VectorUpdate keeps only what retail'sPhysicsDescwrite does: install the wire velocity. - Make Gravity persistent on remote bodies, matching
PlayerMovementController.cs:689: constructRemoteMotion.BodywithPhysicsStateFlags.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'sGRAVITY_PS(0x400) is an object property, and bothHitGround(:2435) andcontact_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/smagic threshold and the wire dependency for the whole ground-edge family. - NPCs / monsters — same code path (
#184Slice 2b unified them; there is no player/NPC fork inTick). They gain a correctLeaveGroundwhen 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 byprojectileHandlesMovement(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.Airbornewire-latch deviation, and it touches AD-25's call-site note atRuntimeRemotePhysicsUpdater.cs:449-470(the comment claims the directHandleAllCollisionscall matches "retail's own unconditional call site" — it does not, because retail's call site is insideSetPositionInternal, afterset_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-326advances the sequencer and captures the pose, then:351-364runs_remotePhysics.Tick(whereHitGroundfires). So a cycle change lands in the next quantum's pose — a one-frame lag, not a multi-second one.LiveEntityMotionRuntimeController.cs:39-47createsrm.Sinkonce overae.Sequencerand binds it asrm.Motion.DefaultSink(:46). The same cached sink is handed to the inbound funnel atLiveEntityNetworkUpdateController.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.RemoveLinkAnimationsis bound tosequencer.Manager.HandleEnterWorld(LiveEntityMotionRuntimeController.cs:63), which is the correct#174binding.
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):
- Edge from contact, not from the wire. Drive
RuntimeRemotePhysicsUpdater.Tickwith a stubbed resolve returningInContact=false, OnWalkable=falsethenInContact=true, OnWalkable=true, with no VectorUpdate ever delivered. AssertHitGroundfired exactly once on the transition andLeaveGroundexactly once on the opposite one. This fails today (C1). - Steep contact does not land. Resolve returns
InContact=truewith a contact normal whoseZ < PhysicsGlobals.FloorZ. AssertOnWalkablestays clear andHitGrounddid not fire. This is the Bug B assertion at the same site. - Idempotence. Two consecutive grounded quanta fire
HitGroundonce, not twice. - Gravity persistence. After a landing, assert
rm.Body.State.HasFlag(PhysicsStateFlags.Gravity)— then run a second airborne→grounded cycle and assertHitGroundfires again. This fails today (C2). - End-to-end cycle exit. With a real
MotionTableDispatchSinkover a testAnimationSequencerseeded toSubstate = FallingandInterpretedState.ForwardCommand = Ready, assertCurrentMotionleaves0x40000015after the grounded commit. This is the assertion that makes the §6.1 table unnecessary going forward. - 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.Airbornenever 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
- 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. - Whether ACE emits a
0x4x-class motion command for a falling player (§5.4). Settled byACDREAM_DUMP_MOTION=1+ACDREAM_REMOTE_VEL_DIAG=1across a remote jump. - Whether the humanoid MotionTable's
Readycycle carriesMotionData.Bitfield & 2. This is DAT content, not source. If set,CMotionTable.IsAllowed(:172-188, retailis_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 identicalGetObjectSequencecall and does clear the pose — but it is not proven. Settle by dumpingMotionData.Bitfieldfor cycle key(style << 16) | 0x000003, or withbp acclient!CMotionTable::is_allowedreading[edx+0x30]. - Whether ACE sets
GRAVITY_PS (0x400)in the broadcastPhysicsDescfor 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_movedumpingphysics_obj->state.