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>
559 lines
30 KiB
Markdown
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`.
|