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>
This commit is contained in:
parent
f058dfc9f9
commit
204d0ae047
11 changed files with 3103 additions and 245 deletions
157
docs/ISSUES.md
157
docs/ISSUES.md
|
|
@ -9609,12 +9609,161 @@ other pieces that are either incomplete or unverified for remotes:
|
|||
contact-plane-derived slide, not the terrain-only approximation AD-10
|
||||
already flags as a divergence.
|
||||
|
||||
No fix has been applied for this observation — the investigation stopped
|
||||
here per project policy (no workarounds without approval) and instrumented a
|
||||
probe (`ACDREAM_PROBE_REMOTE_LANDING`, `PhysicsDiagnostics.cs`) instead. See
|
||||
The investigation stopped there per project policy and instrumented a probe
|
||||
(`ACDREAM_PROBE_REMOTE_LANDING`, then the `[remote-slide-*]` family in
|
||||
`PhysicsDiagnostics.cs`). See
|
||||
`docs/research/2026-08-04-remote-landing-investigation.md` for the companion
|
||||
Bug A (falling-animation-lingers) hypothesis set and the probe's decision
|
||||
table.
|
||||
table, and `docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md` for the
|
||||
full four-link chain.
|
||||
|
||||
**Bug B FIXED 2026-08-04 (awaiting the user's two-client visual gate).** The
|
||||
live capture settled it: two adjacent ticks 63 ms apart on a 52.4-degree roof
|
||||
(contact-plane `Normal.Z` 0.6097 against `FloorZ` 0.6642) showed the sweep
|
||||
reporting `rsInContact=True rsOnWalkable=False`, and the tick then committing
|
||||
`contact=True onWalkable=True gravity=False velBeforeZero=(2.146,2.264,0.000)`
|
||||
and `moved=0.0000` on every tick afterwards. **acdream's classifier was
|
||||
correct and was being overruled.** Four independent writes did it, and all four
|
||||
are gone:
|
||||
|
||||
1. `RuntimeRemotePhysicsUpdater.Tick` asserted
|
||||
`TransientState |= Contact | OnWalkable` on every tick a remote was not
|
||||
flagged airborne. Retail writes CONTACT_TS from
|
||||
`collision_info.contact_plane_valid` (`CPhysicsObj::SetPositionInternal`
|
||||
@0x00515330, 0x00515430) and routes ON_WALKABLE_TS through
|
||||
`set_on_walkable` (@0x00511310) purely on
|
||||
`contact_plane.N.z >= PhysicsGlobals::floor_z` (0x00515465-0x0051548E).
|
||||
With both bits forced, `calc_acceleration` (@0x00510950) returned zero
|
||||
acceleration and `calc_friction` (@0x0050EE70) — whose entire body sits
|
||||
inside `transient_state & 2` — could not engage either.
|
||||
2. The same block zeroed `Body.Velocity`. Retail's `MoveOrTeleport`
|
||||
(@0x00516330) never reads or writes a remote's wire velocity at all; the
|
||||
zeroing discarded both the authoritative `0xF74E` vector and everything
|
||||
gravity had accumulated.
|
||||
3. The tick consumed only `ResolveResult.Position/CellId/IsOnGround` and called
|
||||
`HandleAllCollisions` bare — the TAIL of `SetPositionInternal` without its
|
||||
prefix. The sweep's own `InContact`/`OnWalkable` were never committed, and
|
||||
the landing edge was decided from `IsOnGround`, which is `inContact || ...`
|
||||
and is therefore TRUE on a steep contact. The tick now runs the full
|
||||
`PhysicsObjUpdate.CommitSetPositionTransition` sequence (contact prefix ->
|
||||
`set_on_walkable` edge -> `handle_all_collisions` @0x005154FE), gated on
|
||||
`Ok && candidateMoved` exactly like `PlayerMovementController` and retail
|
||||
`UpdateObjectInternal` (pc:283657).
|
||||
4. Both landing blocks cleared `PhysicsStateFlags.Gravity`. Retail never
|
||||
toggles GRAVITY_PS on a ground edge: the `CPhysicsObj` constructor seeds it
|
||||
(state `0x400C08` @0x00512508) and `set_state` (@0x00514DD0) assigns the
|
||||
description's state wholesale, post-processing only lighting/nodraw/hidden.
|
||||
The matching `State |= Gravity` in the VectorUpdate jump handler is deleted
|
||||
too, so the bit is now wire-owned end to end.
|
||||
|
||||
Consequential cleanups in the same change: `MovementManager::HitGround` now has
|
||||
the single retail source it has in the binary — the `set_on_walkable(1)`
|
||||
edge — so the packet-side landing block no longer dispatches its own
|
||||
(which would have double-fired the landing re-apply); and `RemoteMotion.Airborne`
|
||||
is now derived on the SetPositionInternal commit from the committed
|
||||
`Body.OnWalkable`, which is the project's one existing definition of the flag
|
||||
(`PlayerMovementController.IsAirborne`, the spawn settle,
|
||||
`RemoteTeleportPlacement`, `TickHidden`). Only the fact it derives FROM moved,
|
||||
from the hand-rolled `IsOnGround` test to the sweep's contact-plane result.
|
||||
|
||||
**Known follow-up, deliberately not changed here — now register row AP-140.**
|
||||
Because `Airborne` remains `!OnWalkable`, a remote sliding on a steep face is
|
||||
classified airborne, so an accepted grounded Position takes the
|
||||
`AirborneSnap`/landing-snap arm and hard-snaps to the server position at UP
|
||||
cadence instead of feeding the interpolation queue. Retail's predicate for that
|
||||
same decision is the CONTACT transient, not walkability
|
||||
(`InterpolationManager::adjust_offset` @0x00555D30 gates its whole body on
|
||||
`transient_state & 1` @0x00555D52), so a retail body in contact with a
|
||||
non-walkable face keeps interpolating. The two disagree on exactly one state,
|
||||
and this fix turned that state from unreachable (the deleted forge made every
|
||||
non-airborne remote walkable by construction) into ordinary — which is why it
|
||||
is now a filed divergence rather than an unremarked one. The bound is one UP
|
||||
interval to the authoritative position, and the between-packet motion is now a
|
||||
genuine local slide rather than a freeze, so the composite reads as continuous.
|
||||
|
||||
**The follow-up slice is re-shaped (2026-08-04 review): do NOT re-derive
|
||||
`Airborne` from CONTACT.** That was prepared and backed out here because it
|
||||
perturbs all five `Airborne = !Body.OnWalkable` writers and contradicts a
|
||||
pinned assertion in
|
||||
`RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_RestoresSourceWalkabilityForFirstAcceleration`
|
||||
(`InContact: true, OnWalkable: false` → `Assert.True(remote.Airborne)`). The
|
||||
right change is smaller: point the **two routing gates**
|
||||
(`ApplyRemoteContactRouting`'s `if (remote.Airborne)` and `OnPosition`'s
|
||||
player-remote `if (rmState.Airborne)`) at `remote.Body.InContact` directly and
|
||||
leave the `Airborne` flag alone. That is the literal retail predicate at the
|
||||
one place the predicate is used, and it touches no existing test.
|
||||
|
||||
**AP-87's 4 m snap is deliberately untouched.** It is the #184
|
||||
invisible-but-solid backstop; this change removes the CAUSE of the divergence
|
||||
that made it fire, and the expected consequence is that it fires far less often.
|
||||
`InterpolationManager`'s `node_fail_counter > 3` stall snap is likewise
|
||||
untouched — the capture confirmed `producer=ap87-4m`, so the stall snap was
|
||||
never the producer here, and it is a faithful port of
|
||||
`InterpolationManager::UseTime` @0x00555f20.
|
||||
|
||||
Register: **AP-81** narrowed (its whole GRAVITY half retired), **AP-87**
|
||||
annotated, **AP-139** filed for the interpolation-queue clear the deleted
|
||||
landing block used to own, **AP-140** filed at the review for the
|
||||
walkability-vs-CONTACT routing predicate above. Coverage:
|
||||
`tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteepContactSlideTests.cs`
|
||||
(10 tests over a synthetic constant-gradient ramp, each individually
|
||||
discriminated against a reverted fix).
|
||||
|
||||
**Recorded gaps from the 2026-08-04 Opus review — the fix PASSED; these are
|
||||
known, deliberately unfixed, and none of them was changed in the tightening
|
||||
pass that recorded them.**
|
||||
|
||||
- **The `LeaveGround` dispatch is new and unbounded.** The
|
||||
`previousOnWalkable && !finalOnWalkable` arm calls
|
||||
`MotionInterpreter.LeaveGround()`, which is a per-remote dispatch acdream
|
||||
never made before. It is retail-shaped (`CMotionInterp::LeaveGround`
|
||||
@0x00528B00 — creature gate @0x00528B36, Gravity-state gate, then the
|
||||
velocity install @0x00528B66), but note what it DOES: `GetLeaveGroundVelocity`
|
||||
(@0x005280c0) **replaces** the body's velocity with `get_state_velocity()`
|
||||
plus a jump Z, then `RemoveLinkAnimations` + `apply_current_movement`
|
||||
re-dispatches motion. Nothing bounds how often it can fire: the suite bounds
|
||||
the HitGround edge at exactly 1
|
||||
(`WalkableLandingStillLandsAndFiresTheGroundEdgeOnce`) but has no
|
||||
counterpart for LeaveGround, and noisy geometry — a
|
||||
walkable lip alternating with a steep face across the sweep — could chatter
|
||||
the edge and re-dispatch motion every tick. Compare the #270 lesson in
|
||||
`claude-memory/project_physics_collision_digest.md`: a per-stats-refresh
|
||||
`ReportExhaustion` re-dispatch produced 490 spurious stance re-queues in one
|
||||
session; **never re-add a per-tick re-apply.** A LeaveGround-count bound is
|
||||
the missing test.
|
||||
- **The primary watch item for the visual gate: action animations on remotes
|
||||
that fail to establish contact.** The deleted per-tick force was, in
|
||||
practice, a blanket guarantee that every non-airborne remote carried
|
||||
`Contact | OnWalkable`. `contact_allows_move` (@0x00528dd0) **silently
|
||||
refuses action animations** for a body lacking both — that is the exact root
|
||||
cause of closed issue **#270** ("monster attacks but the animation never
|
||||
fires", "stuck in cast pose"). Post-fix, any remote whose sweep fails to
|
||||
establish contact loses its attack/cast animations. On a 52.4-degree roof
|
||||
that is retail-correct and is the point of the change. Anywhere else it is
|
||||
the `feedback_latent_bug_masked_by_fallback` shape: the forge was masking
|
||||
contact failures, and removing it exposes every one of them. **Watch for
|
||||
missing attack/cast animations on ordinary flat ground during the two-client
|
||||
gate; if any appear, the bug is in contact establishment, not in this fix.**
|
||||
- **A remote whose transition keeps failing never re-derives `Airborne`.** The
|
||||
whole SetPositionInternal commit is gated on `Ok && candidateMoved`, and
|
||||
`rm.Airborne = !rm.Body.OnWalkable` is assigned only inside it, while the
|
||||
packet-side landing block no longer clears `Airborne` either. A remote whose
|
||||
transition keeps returning `!Ok` — the #116/#182 wedge class — therefore
|
||||
stays flagged airborne indefinitely: it keeps integrating gravity, and every
|
||||
grounded UP hard-snaps it back, producing sink-and-snap jitter at UP cadence.
|
||||
Bounded (each snap is to the authoritative position) and its reachability is
|
||||
unproven, but it is new with this change and did not exist while the forge
|
||||
ran.
|
||||
|
||||
Still open on this row: the two dependencies named above. **#173**'s remote
|
||||
collision-velocity reflect now genuinely runs on a steep contact (the old code
|
||||
passed `IsOnGround` as `nowOnWalkable`, which suppressed the reflect exactly
|
||||
where retail forces it), but its visual gate is still unrun. **AD-10**'s
|
||||
terrain-only slope projection still cannot see building geometry; it is now
|
||||
correctly gated OFF while the body is not on walkable ground, so a roof slide
|
||||
is driven by gravity plus the sweep's own plane projection rather than by that
|
||||
approximation. The retail-strict `step_up_slide`/`cliff_slide` audit that this
|
||||
row was originally filed for is unchanged.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
559
docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md
Normal file
559
docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
# 2026-08-04 — Bug A / H3 diagnosis: why a remote's detected landing edge does not advance its animation
|
||||
|
||||
**Status:** REPORT-ONLY. No source or test edits. Companion to
|
||||
`docs/research/2026-08-04-remote-landing-investigation.md` (hypotheses + decision
|
||||
table) and `docs/ISSUES.md` #32.
|
||||
|
||||
**Capture that drives this report:** `launch-4b2.log:809-827`, six
|
||||
`[remote-landing]` lines for guid `0x5000000F` between `t=85680843` and
|
||||
`t=85748328` (67.5 s), all identical:
|
||||
`airborneBefore=True gravitySet=True contact=True onWalkable=True
|
||||
hasDefaultSink=True resolveIsOnGround=True seqStyle=0x8000003D
|
||||
seqMotion=0x40000015`. Four `site=per-tick`, two `site=controller`.
|
||||
`ACDREAM_DUMP_MOTION` was NOT enabled for this run, so there are no
|
||||
`VU`/`VU.land`/`UM`/`SetCycle` lines to correlate against.
|
||||
|
||||
---
|
||||
|
||||
## Headline
|
||||
|
||||
**Retail leaves the Falling cycle on landing as a purely LOCAL consequence of
|
||||
the observer's own physics.** The driver is the false→true edge of the
|
||||
`ON_WALKABLE_TS` bit inside `CPhysicsObj::set_on_walkable`
|
||||
(`named symbol @0x00511310`, pseudo-C :279287), which is reached
|
||||
unconditionally from `CPhysicsObj::SetPositionInternal`
|
||||
(`@0x00515330`, :283399) on every completed transition, for every object —
|
||||
there is no `IsThePlayer` / `IsCreature` / ownership gate anywhere on that path.
|
||||
|
||||
**acdream has a verbatim port of that edge**
|
||||
(`src/AcDream.Core/Physics/PhysicsObjUpdate.cs:137-142`) and wires it for the
|
||||
local player, for ordinary bodies, for placements, for spawn settle, and even
|
||||
for *hidden* remotes. **The one path that does not use it is the visible
|
||||
remote per-tick tick** — `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:471`
|
||||
calls `HandleAllCollisions` alone, skipping the whole `SetPositionInternal`
|
||||
contact/on_walkable prefix that owns the HitGround/LeaveGround edge, and
|
||||
substitutes a hand-rolled, **wire-latched** landing heuristic at
|
||||
`:493-580`.
|
||||
|
||||
That substitution is the defect. Its two measurable consequences are named in
|
||||
§2 and §5. This is the same site and the same root as Bug B (ISSUES #32's
|
||||
2026-08-04 addendum): the remote's `OnWalkable` bit is *asserted*, never
|
||||
*derived*.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where a remote's animation sequence is advanced — every writer
|
||||
|
||||
`seqMotion` is `AnimationSequencer.CurrentMotion`, a read-only mirror of
|
||||
`MotionState.Substate` (`src/AcDream.Core/Physics/AnimationSequencer.cs:126`;
|
||||
style at `:117`). `MotionState.Substate` is written in exactly two places:
|
||||
`src/AcDream.Core/Physics/Motion/CMotionTable.cs:323` (Branch 1, style change)
|
||||
and `:414` (Branch 2, cycle change), both inside `GetObjectSequence`
|
||||
(`:255-516`), plus the baseline install at `:622` (`SetDefaultState`).
|
||||
|
||||
Every route into `GetObjectSequence` for a live entity:
|
||||
|
||||
| # | Entry point | Reachable for a PLAYER remote? |
|
||||
|---|---|---|
|
||||
| W1 | `MotionTableDispatchSink.ApplyMotion/StopMotion/StopCompletely` (`src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs:34-57`) → `AnimationSequencer.PerformMovement` (`:473-477`) → `MotionTableManager.PerformMovement` (`src/AcDream.Core/Physics/Motion/MotionTableManager.cs:409-444`) | **Yes — the only live route.** |
|
||||
| W2 | `AnimationSequencer.SetCycle` (`:353-413`) from `RemoteServerControlledVelocityCycle.cs:78` | **No** — gated `!IsPlayerGuid(serverGuid)` at `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:181`. NPC/monster only. |
|
||||
| W3 | `AnimationSequencer.SetCycle` from `src/AcDream.App/Rendering/SpawnMotionInitializer.cs:34,60` | Spawn only. |
|
||||
| W4 | `AnimationSequencer.SetCycle`/`PlayAction` from `src/AcDream.Core/Physics/AnimationCommandRouter.cs:78,81` | Command/emote routing, not locomotion. |
|
||||
| W5 | `MotionTableManager.InitializeState` (`:353-362`) / `Reset` (`AnimationSequencer.cs:598-609`) | Lifecycle only. |
|
||||
|
||||
Note: the `SetCycle` block advertised at
|
||||
`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:413-463` is
|
||||
**dead for the cycle** — `fullMotion` computed there is immediately overwritten
|
||||
by the funnel dispatcher's result at `:602` and used only for the
|
||||
locomotion-timestamp bookkeeping at `:630-638`. The comment block is stale; do
|
||||
not read it as a second cycle writer.
|
||||
|
||||
**So for a player remote there is exactly ONE writer, W1, and exactly two
|
||||
things drive it:**
|
||||
|
||||
- **(a) the inbound wire funnel** — `RemoteInboundMotionDispatcher.Apply`
|
||||
(`src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs:112`) →
|
||||
`MotionInterpreter.MoveToInterpretedState`
|
||||
(`src/AcDream.Core/Physics/MotionInterpreter.cs:2741-2788`) →
|
||||
`ApplyInterpretedMovement` (`:2764`).
|
||||
- **(b) the local ground edges** — `MotionInterpreter.LeaveGround` (`:2374-2395`)
|
||||
and `HitGround` (`:2426-2444`), each ending in
|
||||
`apply_current_movement` (`:1460-1473`) →
|
||||
`ApplyCurrentMovementInterpreted` (`:1547-1563`) →
|
||||
the **same** `ApplyInterpretedMovement` (`:2842-2903`) through the **same**
|
||||
`DefaultSink`.
|
||||
|
||||
**Answer to "can anything other than an inbound `UpdateMotion` move a remote
|
||||
out of Falling?" — Yes, exactly one thing: `HitGround` (b).** There is no
|
||||
timer, no per-frame recompute, and no NPC-style velocity-cycle fallback for a
|
||||
player remote. If (b) is a no-op, the wire is the only escape — which is
|
||||
precisely the reported symptom.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where the landing edge is detected, and what each site does with it
|
||||
|
||||
Two sites, both real, neither of which silently drops the edge:
|
||||
|
||||
**Site A — `site=per-tick`**, `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:493-580`
|
||||
```
|
||||
:493 if (rm.Airborne && resolveResult.IsOnGround && rm.Body.Velocity.Z <= 0f)
|
||||
:497 rm.Airborne = false;
|
||||
:502 rm.Interp.Clear();
|
||||
:503-504 rm.Body.TransientState |= Contact | OnWalkable; // ASSERTED, not derived
|
||||
:505-506 rm.Body.Velocity = (X, Y, 0)
|
||||
:538-557 [remote-landing] probe
|
||||
:559 rm.Movement.HitGround();
|
||||
:574-576 rm.Body.State &= ~Gravity;
|
||||
```
|
||||
|
||||
**Site B — `site=controller`**, `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2019-2117`
|
||||
```
|
||||
:2019 if (rmState.Airborne)
|
||||
:2021 rmState.Airborne = false;
|
||||
:2023-24 rmState.Body.TransientState |= Contact | OnWalkable; // ASSERTED, not derived
|
||||
:2065-69 EnsureRemoteMotionBindings (creates the sink if missing)
|
||||
:2077-96 [remote-landing] probe
|
||||
:2100 rmState.Movement.HitGround();
|
||||
:2114-15 rmState.Body.State &= ~Gravity;
|
||||
```
|
||||
|
||||
Both reach `MovementManager.HitGround`
|
||||
(`src/AcDream.Core/Physics/Motion/MovementManager.cs:153-156`) →
|
||||
`MotionInterpreter.HitGround`. **The edge is not dropped at either site.** Under
|
||||
the captured probe values every gate in `HitGround` passes:
|
||||
`PhysicsObj` non-null (`MotionInterpreter.cs:2428`), `IsCreature` true
|
||||
(`RemoteWeenie` inherits the `IWeenieObject.IsCreature() => true` default at
|
||||
`MotionInterpreter.cs:462`), Gravity set (`:2435`, probe `gravitySet=True`),
|
||||
`Initted` true by default (`:747`). `apply_current_movement`'s dual dispatch
|
||||
(`:1465-1470`) routes a remote to the INTERPRETED branch because
|
||||
`RemoteWeenie.IsThePlayer()` is the `false` default (`:475`), and
|
||||
`ApplyCurrentMovementInterpreted` takes the sink branch (`:1558-1563`) because
|
||||
`DefaultSink` is bound (probe `hasDefaultSink=True`).
|
||||
|
||||
**The real defect is not that the edge is dropped — it is that the edge is
|
||||
INVENTED.** `rm.Airborne` is an acdream latch, not retail state. It is set at
|
||||
exactly one place:
|
||||
|
||||
```
|
||||
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:1265-1273
|
||||
if (update.Velocity.Z > 0.5f) { // 0xF74E VectorUpdate only
|
||||
rm.Airborne = true;
|
||||
rm.Body.TransientState &= ~(Contact | OnWalkable);
|
||||
rm.Body.State |= PhysicsStateFlags.Gravity;
|
||||
...
|
||||
:1290 rm.Motion.LeaveGround();
|
||||
}
|
||||
```
|
||||
(the other setter, `RemoteTeleportController.cs:399`, is the teleport path).
|
||||
|
||||
So for a visible remote, **both** ground edges and **both** the Gravity bit and
|
||||
the contact bits are driven by one wire packet with a `0.5 m/s` magic
|
||||
threshold, rather than by the resolved contact plane. Retail's contact bits are
|
||||
derived from `contact_plane.N.z >= floor_z` on every transition
|
||||
(`@0x00515330`, :283501-283509) and `GRAVITY_PS (0x400)` is a persistent object
|
||||
property, never a per-jump latch.
|
||||
|
||||
Two direct consequences, both citable:
|
||||
|
||||
- **C1 — the edge cannot fire at all for an airborne episode that does not
|
||||
begin with a `+Z > 0.5` VectorUpdate** (walk off a ledge, step off a porch,
|
||||
a dropped/reordered `0xF74E`). In that case `LeaveGround` also never runs, so
|
||||
Falling would instead have to be engaged by the wire funnel's own airborne
|
||||
substitution (`MotionInterpreter.cs:2864-2868`) — which requires Gravity set,
|
||||
which also never happened. The remote then falls with its grounded cycle
|
||||
playing.
|
||||
- **C2 — Gravity is cleared immediately after the first landing edge**
|
||||
(`RuntimeRemotePhysicsUpdater.cs:574-576`,
|
||||
`LiveEntityNetworkUpdateController.cs:2114-2115`), whereas the local player's
|
||||
body is constructed with Gravity (`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:689`)
|
||||
and never clears it. With Gravity clear, `contact_allows_move` returns `true`
|
||||
early (`MotionInterpreter.cs:2142-2143`) and `HitGround` no-ops outright
|
||||
(`:2435`) — so a *second* airborne episode before the next VectorUpdate has no
|
||||
animation response at all in either direction.
|
||||
|
||||
Neither C1 nor C2 explains the six captured edges (all had `gravitySet=True`),
|
||||
but both are the same root cause and both must be fixed by the same change.
|
||||
|
||||
---
|
||||
|
||||
## 3. What retail does — LOCAL, unconditional, contact-driven
|
||||
|
||||
Sourced from `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||||
|
||||
**3.1 `CPhysicsObj::set_on_walkable` — `@0x00511310`, :279287** is the driver:
|
||||
```
|
||||
00511336 if ((transient_state & 2) == 0) // OLD value not walkable
|
||||
00511358 if (arg2 != 0) // -> false->true EDGE
|
||||
0051135a movement_manager_1 = this->movement_manager;
|
||||
00511364 MovementManager::HitGround(movement_manager_1);
|
||||
00511336 else if (arg2 == 0) // true->false edge
|
||||
00511346 MovementManager::LeaveGround(movement_manager);
|
||||
```
|
||||
The only condition is `movement_manager != 0`. **No ownership, creature, or
|
||||
player gate.** (`ON_WALKABLE_TS = 0x2`, `docs/research/named-retail/acclient.h:3691`.)
|
||||
|
||||
**3.2 `CPhysicsObj::SetPositionInternal` — `@0x00515330`, :283399** calls it,
|
||||
unconditionally, from the resolved contact plane:
|
||||
```
|
||||
00515430 if (collision_info.contact_plane_valid == 0) ts &= ~1; else ts |= 1; // CONTACT_TS
|
||||
00515465 if ((ts & 1) == 0) { ts &= ~2; MovementManager::LeaveGround(...); }
|
||||
0051548e else if (contact_plane.N.z < PhysicsGlobals::floor_z) set_on_walkable(this, 0); // :283507
|
||||
00515482 else set_on_walkable(this, 1); // :283509
|
||||
005154fe CPhysicsObj::handle_all_collisions(this, &collision_info, ts&1, ts&2);
|
||||
```
|
||||
`handle_all_collisions` (`@0x00514780`, :282647) does **not** touch
|
||||
`on_walkable` — it only does the elasticity reflect and the
|
||||
`frames_stationary_fall` bookkeeping. **The contact/walkable recompute lives
|
||||
exclusively in `SetPositionInternal`, and it runs BEFORE
|
||||
`handle_all_collisions`.**
|
||||
|
||||
Wire-driven placements reach the same code:
|
||||
`CPhysicsObj::SetPositionInternal(Position*, SetPositionStruct*, CTransition*)`
|
||||
(`@0x00515bd0`, :283892) calls the transition form at `@0x00515c94` (:283942).
|
||||
|
||||
**3.3 Retail runs full physics for objects it does not own.**
|
||||
`CPhysics::UseTime` (`@0x00509950`, :271586) iterates the whole object hash and
|
||||
calls `CPhysicsObj::update_object` on every entry (:271643); the only
|
||||
player-specific line is an extra
|
||||
`SmartBox::PlayerPhysicsUpdatedCallback` notification, not a skip.
|
||||
`update_object` (`@0x00515d10`, :283950) early-outs only on
|
||||
`parent != 0 || cell == 0 || (state & 0x1000000)` — nothing about ownership.
|
||||
`UpdateObjectInternal` (`@0x005156b0`, :283611) →
|
||||
`UpdatePositionInternal` (`@0x00512c30`, :280817) →
|
||||
`CPhysicsObj::transition` (`@0x005158b2`) → `SetPositionInternal` (`@0x00515914`).
|
||||
**So on a retail observer, a remote player's landing animation exit is a local
|
||||
physics consequence, not a wire event.**
|
||||
|
||||
**3.4 The re-apply.** `MovementManager::HitGround` (`@0x00524300`, :300425) →
|
||||
`CMotionInterp::HitGround` (`@0x00528ac0`, :305996 — creature gate,
|
||||
`state & 0x400` gravity gate, `RemoveLinkAnimations`,
|
||||
`apply_current_movement(0,0)`) → `apply_current_movement` (`@0x00528870`,
|
||||
:305838; its `IsThePlayer` test is a *source selector* — raw vs interpreted —
|
||||
not a skip) → `apply_interpreted_movement` (`@0x00528600`, :305713):
|
||||
```
|
||||
0052866c if (contact_allows_move(this, interpreted_state.forward_command) == 0)
|
||||
005286ee DoInterpretedMotion(this, 0x40000015, &var_2c); // :305729 FALLING
|
||||
0052866c else
|
||||
00528687 DoInterpretedMotion(this, interpreted_state.forward_command, ...); // :305744
|
||||
```
|
||||
`contact_allows_move` (`@0x00528240`, :305471) returns 1 iff
|
||||
`CONTACT_TS && ON_WALKABLE_TS` (given a gravity-bound creature). **`0x40000015`
|
||||
is dispatched from exactly one site in the entire 1.4 M-line binary** — that
|
||||
one line. Entry to and exit from Falling are both decided by the contact bits.
|
||||
|
||||
**3.5 There is no other landing path.** `symbols.json` contains no
|
||||
`land`/`land_on_ground` animation function (only `CSphere::land_on_sphere`
|
||||
`@0x005379A0` and `CCylSphere::land_on_cylinder` `@0x0053B3D0`, both collision
|
||||
geometry). The complete caller set of `apply_current_movement` is `HitGround`
|
||||
(`@0x00528af7`), `LeaveGround` (`@0x00528b66`), `set_hold_run` (`@0x00528b9e`),
|
||||
`SetHoldKey` (`@0x00528bd4`/`@0x00528bef`), `ReportExhaustion` (`@0x005288ed`),
|
||||
`SetWeenieObject` (`@0x00528955`), and `@0x00528a08`. Landing is `HitGround`
|
||||
and nothing else.
|
||||
|
||||
**3.6 The retail default that matters:** `InterpretedMotionState::InterpretedMotionState`
|
||||
(`@0x0051e8d0`, :293418) sets `forward_command = 0x41000003` (Ready). A remote
|
||||
that never received an mt-0 `UpdateMotion` still re-applies **Ready** on
|
||||
landing, never Falling. acdream matches this
|
||||
(`src/AcDream.Core/Physics/MotionInterpreter.cs:215-224`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Does the local player differ? Yes — and that is the shape of the fix
|
||||
|
||||
**The local player implements retail's contact-derived edge inline.**
|
||||
`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:2607-2645`:
|
||||
```
|
||||
:2608 if (resolveResult.Ok && candidateMoved) {
|
||||
:2610-13 Contact <- resolveResult.InContact
|
||||
:2616 if (resolveResult.InContact && resolveResult.OnWalkable) {
|
||||
:2618 bool wasAirborne = !_body.OnWalkable; // read BEFORE the write
|
||||
:2619 _body.TransientState |= OnWalkable;
|
||||
:2620-27 if (wasAirborne) { Movement.HitGround(); landedThisQuantum = true; }
|
||||
:2630-33 } else { _body.TransientState &= ~OnWalkable; }
|
||||
:2641-44 PhysicsObjUpdate.HandleAllCollisions(...); // AFTER the edge, as retail
|
||||
:2650-51 if (!_body.OnWalkable && !_wasAirborneLastFrame) _motion.LeaveGround();
|
||||
```
|
||||
That is `set_on_walkable`'s edge, derived from the resolved contact plane,
|
||||
with `handle_all_collisions` in retail's order.
|
||||
|
||||
**Four other acdream paths already use the extracted seam:**
|
||||
|
||||
| Path | Call |
|
||||
|---|---|
|
||||
| Local player, hidden/PositionManager | `PlayerMovementController.cs:2044-2053` → `CommitSetPositionTransition(..., Movement.HitGround, _motion.LeaveGround)` |
|
||||
| Ordinary live bodies | `src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.cs:168` |
|
||||
| Canonical placements (incl. remotes) | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs:4912-4919` → `..., remote.HitGround, remote.LeaveGround, guard.IsCurrent` |
|
||||
| **Hidden remotes** | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:778-796` → `CommitSetPositionTransition(..., rm.Movement.HitGround, rm.Motion.LeaveGround, ...)`, then `:796 rm.Airborne = !rm.Body.OnWalkable;` |
|
||||
| Spawn settle | `src/AcDream.Core/Physics/SpawnPlacementSettler.cs:62` |
|
||||
|
||||
The seam is `src/AcDream.Core/Physics/PhysicsObjUpdate.cs:120-151`:
|
||||
```
|
||||
:131 bool finalOnWalkable = CommitSetPositionContactPrefix(body, inContact, onWalkable, previousOnWalkable);
|
||||
:137 if (!previousOnWalkable && finalOnWalkable) hitGround?.Invoke();
|
||||
:143 else if (previousOnWalkable && !finalOnWalkable) leaveGround?.Invoke();
|
||||
```
|
||||
with `finalOnWalkable = inContact && onWalkable` at `:169` and
|
||||
`IsWalkableContact(inContact, n) => inContact && n.Z >= PhysicsGlobals.FloorZ`
|
||||
at `:20-21` — a verbatim port of :283501-283509.
|
||||
|
||||
**The VISIBLE remote per-tick path is the only one that bypasses it.**
|
||||
`RuntimeRemotePhysicsUpdater.cs:471-477` calls `PhysicsObjUpdate.HandleAllCollisions`
|
||||
directly — the *tail* of `SetPositionInternal` without its *prefix* — so the
|
||||
remote's `Contact`/`OnWalkable` bits are never derived from the resolve, the
|
||||
false→true edge never exists, and `HitGround`/`LeaveGround` have no driver.
|
||||
The `rm.Airborne` latch at `:493` is the stand-in.
|
||||
|
||||
**So yes: the same mechanism is simply not wired for visible remotes, and it
|
||||
is already wired 300 lines below in the same file for hidden ones.** The fix is
|
||||
small.
|
||||
|
||||
---
|
||||
|
||||
## 5. Proposed fix
|
||||
|
||||
### 5.1 The change (H3's file, per the investigation doc's three-file split)
|
||||
|
||||
The investigation doc says H1's fix is in the Gravity-clear sites, H2's is in
|
||||
the sink-binding race, and **H3's is "the animation-scheduler consumption
|
||||
path, not physics."** That framing needs one correction, which this trace
|
||||
establishes: the consumption path is fine (see §5.3) — H3's actual file is
|
||||
**`src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`**, the *producer*
|
||||
of the animation command, not `LiveEntityAnimationScheduler`/`Presenter`.
|
||||
|
||||
**Target: `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:471-580`.**
|
||||
|
||||
Replace the direct `HandleAllCollisions` call at `:471-477` **and** the entire
|
||||
hand-rolled landing block at `:493-580` with the same call the hidden path
|
||||
already makes at `:778-796`:
|
||||
|
||||
```csharp
|
||||
// retail CPhysicsObj::SetPositionInternal @0x00515330 (:283501-283509) ->
|
||||
// set_on_walkable @0x00511310 (:279287): contact/on_walkable derived from the
|
||||
// contact plane, HitGround/LeaveGround on the false<->true edge, then
|
||||
// handle_all_collisions @0x00514780. Same order, same seam as the hidden
|
||||
// remote path below and the local player at PlayerMovementController:2607.
|
||||
if (!PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
rm.Body,
|
||||
resolveResult.InContact,
|
||||
resolveResult.OnWalkable, // DERIVED, not asserted
|
||||
resolveResult.CollisionNormalValid,
|
||||
resolveResult.CollisionNormal,
|
||||
previousContact,
|
||||
previousOnWalkable,
|
||||
rm.Movement.HitGround,
|
||||
rm.Motion.LeaveGround,
|
||||
() => IsCurrentOwner(record, rm, objectClockEpoch, externalOwnerValid)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
rm.Airborne = !rm.Body.OnWalkable; // derived state, not a wire latch
|
||||
```
|
||||
|
||||
Three supporting deletions/changes fall out of it, all required for the seam to
|
||||
be the single owner:
|
||||
|
||||
1. **Delete the unconditional `TransientState |= Contact | OnWalkable`** at
|
||||
`:152-154` (the `!rm.Airborne` per-tick force) — it is what makes the edge
|
||||
structurally impossible. This is also Bug B's forced-`OnWalkable`
|
||||
(ISSUES #32 addendum), so the two bugs close together.
|
||||
2. **Delete the twin landing block** at
|
||||
`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2019-2117`'s
|
||||
`TransientState |= Contact | OnWalkable` + `HitGround` + Gravity-clear, and
|
||||
the `rm.Airborne = true` + contact-clear + Gravity-set at `:1265-1273`.
|
||||
`LeaveGround` at `:1290` goes with them — retail fires it from
|
||||
`set_on_walkable`, not from a `0xF74E` handler. The VectorUpdate keeps only
|
||||
what retail's `PhysicsDesc` write does: install the wire velocity.
|
||||
3. **Make Gravity persistent on remote bodies**, matching
|
||||
`PlayerMovementController.cs:689`: construct `RemoteMotion.Body` with
|
||||
`PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions`
|
||||
(`src/AcDream.Runtime/Physics/RemoteMotion.cs:278`) and delete both
|
||||
Gravity-clear sites (`RuntimeRemotePhysicsUpdater.cs:574-576`,
|
||||
`LiveEntityNetworkUpdateController.cs:2114-2115`). Retail's `GRAVITY_PS`
|
||||
(`0x400`) is an object property, and both `HitGround` (`:2435`) and
|
||||
`contact_allows_move` (`:2142`) read it as one. Without this, C2 (§2)
|
||||
survives the fix.
|
||||
|
||||
This is *not* a symptom-site guard: it deletes the invented mechanism and
|
||||
installs the retail one that the rest of the codebase already uses.
|
||||
|
||||
### 5.2 Blast radius
|
||||
|
||||
- **Player remotes** — the intended target. Landing/leaving-ground animation
|
||||
becomes local and unconditional, as retail. Also removes the `0.5 m/s`
|
||||
magic threshold and the wire dependency for the whole ground-edge family.
|
||||
- **NPCs / monsters** — same code path (`#184` Slice 2b unified them; there is
|
||||
no player/NPC fork in `Tick`). They gain a correct `LeaveGround` when they
|
||||
walk off a ledge, which they do not have today. Their separate
|
||||
stale-velocity cycle stop (`RuntimeRemotePhysicsUpdater.cs:181-192` →
|
||||
`RemoteServerControlledVelocityCycle.cs:78`) is untouched.
|
||||
- **Local player** — untouched. It already does this inline
|
||||
(`PlayerMovementController.cs:2607-2645`).
|
||||
- **Projectiles** — untouched. They use
|
||||
`ProjectilePhysicsStepper.cs:374` (`ApplySetPositionContact`) and are
|
||||
excluded from this branch by `projectileHandlesMovement`
|
||||
(`LiveEntityAnimationScheduler.cs:336`).
|
||||
- **Hidden remotes / placements / spawn settle** — untouched; they already
|
||||
call the seam.
|
||||
- **Bug B** — expected to change behavior at the same commit, because a steep
|
||||
roof stops being force-marked `OnWalkable`. §5.5 covers the coupling.
|
||||
- **Register bookkeeping** — this retires the "forced Contact|OnWalkable on
|
||||
remote landing" deviation and the `rm.Airborne` wire-latch deviation, and it
|
||||
touches AD-25's call-site note at `RuntimeRemotePhysicsUpdater.cs:449-470`
|
||||
(the comment claims the direct `HandleAllCollisions` call matches "retail's
|
||||
own unconditional call site" — it does not, because retail's call site is
|
||||
*inside* `SetPositionInternal`, after `set_on_walkable`). Per CLAUDE.md the
|
||||
register rows must be updated in the same commit.
|
||||
|
||||
### 5.3 What this fix does NOT do — and why the scheduler is exonerated
|
||||
|
||||
The animation-scheduler consumption path was H3's literal wording. It is
|
||||
clean:
|
||||
|
||||
- `LiveEntityAnimationScheduler.cs:323-326` advances the sequencer and captures
|
||||
the pose, **then** `:351-364` runs `_remotePhysics.Tick` (where `HitGround`
|
||||
fires). So a cycle change lands in the *next* quantum's pose — a one-frame
|
||||
lag, not a multi-second one.
|
||||
- `LiveEntityMotionRuntimeController.cs:39-47` creates `rm.Sink` once over
|
||||
`ae.Sequencer` and binds it as `rm.Motion.DefaultSink` (`:46`). The **same**
|
||||
cached sink is handed to the inbound funnel at
|
||||
`LiveEntityNetworkUpdateController.cs:756,766`. A stale-sequencer theory is
|
||||
therefore refuted by observation: if the sink pointed at a dead sequencer,
|
||||
the wire path could not clear the pose either — and the user reports it does.
|
||||
- `RemoveLinkAnimations` is bound to `sequencer.Manager.HandleEnterWorld`
|
||||
(`LiveEntityMotionRuntimeController.cs:63`), which is the correct `#174`
|
||||
binding.
|
||||
|
||||
### 5.4 Second, independent hazard found on the way — flag, do not fix blind
|
||||
|
||||
`MovementParameters.ModifyInterpretedState` defaults to **true**
|
||||
(`src/AcDream.Core/Physics/Motion/MovementParameters.cs:140`), and
|
||||
`MoveToInterpretedState`'s action-replay loop
|
||||
(`MotionInterpreter.cs:2766-2784`) dispatches with ctor-default params
|
||||
(`DispatchInterpretedMotion`, `:3222-3225`). `InterpretedMotionState.ApplyMotion`
|
||||
(`:285-313`) writes `ForwardCommand = motion` for **any** id carrying
|
||||
`0x40000000` (`:299-304`) — which includes `Falling` (`0x40000015`).
|
||||
`InboundInterpretedMotionFactory.Create`
|
||||
(`src/AcDream.App/Physics/InboundInterpretedMotionFactory.cs:46-63`) restores
|
||||
the real class byte via `MotionCommandResolver.ReconstructFullCommand`, whose
|
||||
catalog is built from the DatReaderWriter `MotionCommand` enum — so a wire
|
||||
`Commands[]` entry of `0x0015` would resolve to `0x40000015`.
|
||||
|
||||
If that ever happens, `InterpretedState.ForwardCommand` becomes `Falling`, and
|
||||
`HitGround`'s re-apply at `MotionInterpreter.cs:2878` re-dispatches Falling —
|
||||
producing **exactly** "stuck in Falling until the next motion update
|
||||
overwrites ForwardCommand." This mechanism would survive the §5.1 fix.
|
||||
|
||||
**NOT ESTABLISHED:** whether ACE ever puts a `0x4x`-class command in
|
||||
`Commands[]` for a jumping/falling player. Retail's own outbound cannot
|
||||
(its `ModifyInterpretedState=false` invariant keeps Falling out of
|
||||
`interpreted_state`), but ACE is an emulator. **Settle it with one capture:**
|
||||
`ACDREAM_DUMP_MOTION=1 ACDREAM_REMOTE_VEL_DIAG=1` across a remote jump, and
|
||||
read the `[UM_RAW]`/`[FWD_WIRE]` lines
|
||||
(`LiveEntityNetworkUpdateController.cs:373-386`, `:777-786`) for a
|
||||
`ForwardCommand` or `Commands[]` entry of `0x0015` during the airborne window.
|
||||
|
||||
### 5.5 Sequencing note
|
||||
|
||||
Bug B's addendum (ISSUES #32) records that correcting `OnWalkable` alone may
|
||||
not reproduce retail's roof slide, because it also depends on **#173**'s remote
|
||||
collision-velocity reflect (shipped, gate unrun) and **AD-10**'s terrain-only
|
||||
slope projection. That does not block this fix — the animation edge is
|
||||
independent of the slide response — but expect the roof case to change
|
||||
appearance at the same commit, and gate both together.
|
||||
|
||||
---
|
||||
|
||||
## 6. How to test
|
||||
|
||||
### 6.1 The measurement that is still missing
|
||||
|
||||
**The existing probe reads the wrong side of the call.** It captures state
|
||||
immediately *before* `HitGround` (`RuntimeRemotePhysicsUpdater.cs:538-557`,
|
||||
`LiveEntityNetworkUpdateController.cs:2077-2096`), and the investigation doc
|
||||
already flagged for H3 that "the read happens immediately before HitGround
|
||||
runs, so this alone doesn't distinguish success from H3." The six captured
|
||||
lines therefore prove the gates pass and prove nothing about the outcome.
|
||||
|
||||
Before or alongside the fix, one line of instrumentation settles it: emit a
|
||||
second `[remote-landing-after]` line immediately after `rm.Movement.HitGround()`
|
||||
carrying `seqMotion`, `InterpretedState.ForwardCommand`, and the
|
||||
`MotionTableManagerError` returned by the sink's `ApplyMotion`. Three outcomes,
|
||||
three different conclusions:
|
||||
|
||||
| post-`seqMotion` | `ForwardCommand` | Conclusion |
|
||||
|---|---|---|
|
||||
| Ready (`0x41000003`) | Ready | The edge works; the residual is elsewhere (re-verify the user's observation) |
|
||||
| Falling (`0x40000015`) | Falling | §5.4 — the wire clobbered `ForwardCommand` |
|
||||
| Falling | Ready, sink returned `0x43` | `CMotionTable.IsAllowed` (`CMotionTable.cs:172-188`) refused the Ready cycle because its `MotionData.Bitfield & 2` is set — a DAT fact, not a source fact |
|
||||
|
||||
### 6.2 Automated
|
||||
|
||||
New tests in `tests/AcDream.Runtime.Tests/Physics/` (the layer under test):
|
||||
|
||||
1. **Edge from contact, not from the wire.** Drive `RuntimeRemotePhysicsUpdater.Tick`
|
||||
with a stubbed resolve returning `InContact=false, OnWalkable=false` then
|
||||
`InContact=true, OnWalkable=true`, with **no** VectorUpdate ever delivered.
|
||||
Assert `HitGround` fired exactly once on the transition and `LeaveGround`
|
||||
exactly once on the opposite one. This fails today (C1).
|
||||
2. **Steep contact does not land.** Resolve returns `InContact=true` with a
|
||||
contact normal whose `Z < PhysicsGlobals.FloorZ`. Assert `OnWalkable` stays
|
||||
clear and `HitGround` did **not** fire. This is the Bug B assertion at the
|
||||
same site.
|
||||
3. **Idempotence.** Two consecutive grounded quanta fire `HitGround` once, not
|
||||
twice.
|
||||
4. **Gravity persistence.** After a landing, assert
|
||||
`rm.Body.State.HasFlag(PhysicsStateFlags.Gravity)` — then run a second
|
||||
airborne→grounded cycle and assert `HitGround` fires again. This fails today
|
||||
(C2).
|
||||
5. **End-to-end cycle exit.** With a real `MotionTableDispatchSink` over a test
|
||||
`AnimationSequencer` seeded to `Substate = Falling` and
|
||||
`InterpretedState.ForwardCommand = Ready`, assert `CurrentMotion` leaves
|
||||
`0x40000015` after the grounded commit. This is the assertion that makes the
|
||||
§6.1 table unnecessary going forward.
|
||||
6. **Regression guard on the existing contract:**
|
||||
`tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs:201`
|
||||
(`HitGround_AfterFall_RedispatchesPreservedForward_ExitsFalling`) already
|
||||
covers the interp layer with a fake sink and must stay green.
|
||||
|
||||
### 6.3 What the user would see
|
||||
|
||||
Two-client route 4a, retail driving `+Acdream`'s neighbour:
|
||||
|
||||
- **Jump on flat ground** — the falling pose clears the instant the remote's
|
||||
feet touch, with no wire round-trip and no wait for the next `UpdateMotion`.
|
||||
- **Walk off a low ledge / porch step** (no jump packet at all) — the remote
|
||||
now plays the falling cycle on the way down and exits it on landing. Today it
|
||||
keeps its grounded cycle the whole way, because `rm.Airborne` never latches.
|
||||
- **Two jumps in quick succession** — the second one animates identically to
|
||||
the first (today the Gravity clear can mute it).
|
||||
- **Jump onto a house roof (Bug B)** — the remote should now slide rather than
|
||||
plant, subject to the #173 / AD-10 coupling in §5.5. Gate this together with
|
||||
Campaign P matrix scenario 8.
|
||||
|
||||
---
|
||||
|
||||
## NOT ESTABLISHED
|
||||
|
||||
1. **Which of the two terminal failures actually fires at the captured edges.**
|
||||
The probe measures before the call. §6.1 names the exact one-line
|
||||
instrumentation and the three-way decision table. Everything upstream of the
|
||||
sink call is established: all gates pass, the sink is bound, and the dispatch
|
||||
target is `InterpretedState.ForwardCommand`.
|
||||
2. **Whether ACE emits a `0x4x`-class motion command for a falling player**
|
||||
(§5.4). Settled by `ACDREAM_DUMP_MOTION=1` + `ACDREAM_REMOTE_VEL_DIAG=1`
|
||||
across a remote jump.
|
||||
3. **Whether the humanoid MotionTable's `Ready` cycle carries
|
||||
`MotionData.Bitfield & 2`.** This is DAT content, not source. If set,
|
||||
`CMotionTable.IsAllowed` (`:172-188`, retail `is_allowed @0x005226c0`,
|
||||
:298526) refuses a Ready cycle requested while the substate is Falling,
|
||||
because Falling is not any style's default substate. Observation argues
|
||||
against it — the wire path uses the identical `GetObjectSequence` call and
|
||||
does clear the pose — but it is not proven. Settle by dumping
|
||||
`MotionData.Bitfield` for cycle key `(style << 16) | 0x000003`, or with
|
||||
`bp acclient!CMotionTable::is_allowed` reading `[edx+0x30]`.
|
||||
4. **Whether ACE sets `GRAVITY_PS (0x400)` in the broadcast `PhysicsDesc` for
|
||||
remote creatures.** Relevant only to how faithfully §5.1's item 3 should be
|
||||
implemented (construct-with-Gravity vs adopt-from-wire). cdb:
|
||||
`bp acclient!CMotionInterp::contact_allows_move` dumping
|
||||
`physics_obj->state`.
|
||||
698
docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md
Normal file
698
docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
# 2026-08-04 — Bug B (remote ledge/roof slide) diagnosis
|
||||
|
||||
**Status:** REPORT-ONLY. No source or test edits made. HEAD `7f1c1f5a`.
|
||||
**Companion:** `docs/research/2026-08-04-remote-landing-investigation.md` (Bug A),
|
||||
`docs/ISSUES.md` #32 (2026-08-04 addendum), divergence register rows **AP-87**,
|
||||
**AP-81**, **AD-10**.
|
||||
|
||||
**Relationship to the prior same-day investigation.** ISSUES.md #32 already
|
||||
carries a 2026-08-04 root-cause addendum naming the landing block's
|
||||
unconditional `Contact | OnWalkable`. That addendum is **correct but
|
||||
incomplete**: it names one of *four* independent links in the chain, and the
|
||||
one it names is not the dominant one. Fixing only the landing block cannot
|
||||
produce a slide. §1 below establishes the full chain; §6 states what that means
|
||||
for the fix. Everything here is re-verified against source and against the
|
||||
pseudo-C directly — nothing is carried over on trust.
|
||||
|
||||
---
|
||||
|
||||
## 1. Does acdream locally simulate remote bodies, and would it slide one off a steep roof?
|
||||
|
||||
**It simulates them, and it cannot slide them. Four independent links each
|
||||
block the slide on their own.**
|
||||
|
||||
The per-tick remote owner is
|
||||
`src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`, `Tick(...)`
|
||||
(`:61-650`). It runs once per eligible remote per retail object quantum and
|
||||
*does* call `PhysicsEngine.ResolveWithTransition` (`:370-418`) — the same sweep
|
||||
the local player uses. So the naive framing ("acdream only applies wire
|
||||
positions") is **wrong**: there is a real per-tick sweep. The problem is that
|
||||
everything fed into that sweep has been pre-flattened.
|
||||
|
||||
### Link 1 — the grounded branch force-sets `Contact | OnWalkable` every tick, unconditionally
|
||||
|
||||
`RuntimeRemotePhysicsUpdater.cs:150-154`:
|
||||
|
||||
```csharp
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
rm.Body.TransientState |= TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable
|
||||
| TransientStateFlags.Active;
|
||||
```
|
||||
|
||||
The gate is `rm.Airborne` — an acdream client-side bool — **not** the contact
|
||||
plane and not the wire bit. This is the dominant site: it runs *before every
|
||||
sweep, on every tick*, so it re-asserts the walkable lie even if some other
|
||||
site cleared it. The landing block that ISSUES.md #32 names
|
||||
(`LiveEntityNetworkUpdateController.cs:2023-2024`) and its per-tick twin
|
||||
(`RuntimeRemotePhysicsUpdater.cs:503-504`) fire once each per landing; this one
|
||||
fires ~30 times a second forever.
|
||||
|
||||
### Link 2 — a grounded remote's velocity is zeroed every tick
|
||||
|
||||
`RuntimeRemotePhysicsUpdater.cs:169`:
|
||||
|
||||
```csharp
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
```
|
||||
|
||||
There is nothing left to slide *with*. This also silently discards any
|
||||
authoritative velocity ACE delivered: `OnVector` (0xF74E) writes
|
||||
`update.Velocity` into the body via `TryCommitAuthoritativeVector`
|
||||
(`LiveEntityNetworkUpdateController.cs:1250-1258`), but a downhill slide has
|
||||
`Velocity.Z < 0`, so the `update.Velocity.Z > 0.5f` test at `:1265` leaves
|
||||
`rm.Airborne == false`, and the next tick's `:169` erases the vector.
|
||||
|
||||
### Link 3 — the Gravity state bit is cleared at landing and never restored
|
||||
|
||||
`RuntimeRemotePhysicsUpdater.cs:571-576` and
|
||||
`LiveEntityNetworkUpdateController.cs:2110-2116` both do
|
||||
`rm.Body.State &= ~PhysicsStateFlags.Gravity` after `HitGround()`.
|
||||
`PhysicsBody.calc_acceleration()` (`PhysicsBody.cs:503-518`) returns
|
||||
`Acceleration = Vector3.Zero` when the Gravity bit is clear. The bit is only
|
||||
ever *set* by the jump VectorUpdate (`:1273`) and once at body construction
|
||||
(`RuntimeRemoteBodyDescription.cs:221` / `RuntimePhysicsState.InitializeNewPhysicsBody`,
|
||||
`RuntimePhysicsState.cs:2617`), and that initializer runs **once per
|
||||
incarnation** ("Later SetState never replays them",
|
||||
`RuntimePhysicsState.cs:2608-2612`). So after the first landing the remote has
|
||||
no gravity for the rest of its life.
|
||||
|
||||
Retail does the opposite: GRAVITY_PS is a persistent object property and
|
||||
gravity *acceleration* is gated on the CONTACT/ON_WALKABLE transients inside
|
||||
`calc_acceleration`, not on the state bit being toggled. This is already filed
|
||||
as register row **AP-81** — but AP-81's file:line column lists only the
|
||||
VectorUpdate handler and the landing blocks, not `:150-154` (Link 1).
|
||||
|
||||
### Link 4 — the visible remote tick never commits the sweep's own contact classification
|
||||
|
||||
The engine *does* compute the retail answer. `PhysicsEngine.cs:2653-2676`:
|
||||
|
||||
```csharp
|
||||
bool inContact = ci.ContactPlaneValid;
|
||||
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(inContact, ci.ContactPlane.Normal);
|
||||
bool onGround = inContact || (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
|
||||
```
|
||||
|
||||
and `IsWalkableContact` (`PhysicsObjUpdate.cs:20-21`) is
|
||||
`inContact && contactNormal.Z >= PhysicsGlobals.FloorZ` — retail-shaped. Both
|
||||
`InContact` and `OnWalkable` are carried out on `ResolveResult`
|
||||
(`ResolveResult.cs:46-53`).
|
||||
|
||||
`Tick` reads **only** `resolveResult.Position`, `.CellId`, `.IsOnGround`,
|
||||
`.CollisionNormalValid`, `.CollisionNormal` (`:420-477`). It never reads
|
||||
`.InContact` or `.OnWalkable`, and — unlike `TickHidden`, which calls
|
||||
`PhysicsObjUpdate.CommitSetPositionTransition` at `:778-795` — it never commits
|
||||
them to the body. `PhysicsEngine` itself writes `ContactPlane*`,
|
||||
`WaterContact`, `Sliding`, and the Stationary bits back onto the body, but
|
||||
**not** `Contact`/`OnWalkable` (grep of `PhysicsEngine.cs` for
|
||||
`body.TransientState`: `:2488-2540` only).
|
||||
|
||||
Worse, `IsOnGround` is `inContact || …` — so a **steep-roof contact reports
|
||||
`IsOnGround == true`**. That is the value the airborne→grounded landing
|
||||
detection at `:493-495` tests. The remote therefore "lands" on a surface retail
|
||||
would classify as contact-but-not-walkable, and the landing block then forces
|
||||
`OnWalkable` and clears Gravity.
|
||||
|
||||
**Conclusion for §1:** a remote resting on a steep roof is held there by
|
||||
acdream's own physics, not merely parked at a wire position. The sweep runs
|
||||
every tick and returns "no movement" because the mover has zero velocity, zero
|
||||
acceleration, and a forged walkable contact.
|
||||
|
||||
---
|
||||
|
||||
## 2. What produces the blip
|
||||
|
||||
**Two candidates, both live, and they are distinguishable only by capture.**
|
||||
The first is the one AP-87 predicted; the second is a retail-faithful mechanism
|
||||
firing correctly on a body that has been frozen by §1 — and it fits the user's
|
||||
timing description ("stay there *briefly*, then blip") better.
|
||||
|
||||
### Candidate 1 — AP-87's `bodyToTarget > 4 m` snap
|
||||
|
||||
`src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs:129-137`:
|
||||
|
||||
```csharp
|
||||
bool firstUp = remote.LastServerPosTime <= 0.0;
|
||||
float bodyToTarget = Vector3.Distance(remote.Body.Position, worldPosition);
|
||||
if (firstUp || !willBeDrTicked || bodyToTarget > BodySnapThreshold) // BodySnapThreshold = 4f, :42
|
||||
{
|
||||
remote.Interp.Clear();
|
||||
remote.Body.Position = worldPosition;
|
||||
remote.Body.Orientation = orientation;
|
||||
return Action.Snapped;
|
||||
}
|
||||
```
|
||||
|
||||
This requires the server position to have descended more than 4 m from the
|
||||
parked body. It only fires on a packet that classifies `Interpolate`.
|
||||
|
||||
### Candidate 2 — the InterpolationManager's own stall blip (`node_fail_counter > 3`)
|
||||
|
||||
`src/AcDream.Core/Physics/InterpolationManager.cs:405-467` ports retail's
|
||||
5-frame stall window and its snap-to-tail:
|
||||
|
||||
```csharp
|
||||
if (_frameCounter >= StallCheckFrameInterval) // 5 frames
|
||||
{
|
||||
float cumulative = _originalDistance - dist; // progress made
|
||||
…
|
||||
if (!primaryPass && !secondaryPass) _failCount++; // no progress -> fail
|
||||
else _failCount = 0;
|
||||
}
|
||||
…
|
||||
if (_failCount > StallFailCountThreshold) // > 3, :458
|
||||
{
|
||||
InterpolationNode tail = _queue.Last!.Value;
|
||||
Vector3 tailDelta = tail.TargetPosition - currentBodyPosition;
|
||||
Clear();
|
||||
return new InterpolationStep(true, tailDelta, tail.TargetOrientation);
|
||||
}
|
||||
```
|
||||
|
||||
This is a faithful port of retail `InterpolationManager::UseTime` **@0x00555f20**
|
||||
(pseudo-C :353261): `if (node_fail_counter > 3)` → `SetPositionSimple(physics_obj,
|
||||
<tail or blipto_position>, 1)` @0x0055605D / @0x00556021, then
|
||||
`StopInterpolating` @0x00556061. Verified directly.
|
||||
|
||||
A body frozen by §1 makes **zero** progress every window, so `_failCount`
|
||||
increments on every 5th frame and crosses 3 after ~20 frames — **about 0.33 s at
|
||||
60 Hz**. That is "stay there briefly, then blip", and the blip lands exactly at
|
||||
the tail waypoint, i.e. the server's already-slid-down position.
|
||||
|
||||
The code is not wrong. Retail's own remote would never trip this, because
|
||||
retail's remote is genuinely sliding and therefore genuinely making progress.
|
||||
**This is a downstream symptom of §1, not an independent defect — do not touch
|
||||
`InterpolationManager`.**
|
||||
|
||||
### Elimination of the remaining candidates
|
||||
|
||||
| Candidate | Site | Verdict |
|
||||
|---|---|---|
|
||||
| Far snap (≥96 m) — route 4b-2, just landed at `7f1c1f5a` | `RuntimeRemoteFarSnapPosition.ResolveArm`; classifier `RuntimeAuthoritativePositionRouteClassifier.cs:454-459` (`nearby = PlayerDistance < 96f`) | **Ruled out.** The observer is watching the house; `player_distance` is far under 96 m, so the classification is `Interpolate`, never `SetPositionSimple`. |
|
||||
| `RemoteContactArm.AirborneSnap` | `LiveEntityNetworkUpdateController.cs:1031-1049` | Ruled out for the *post-plant* blip: it requires `remote.Airborne`, which is false once the body has planted (cleared at `:2021` / `:497`). It *is* the mechanism for the initial plant-onto-the-roof snap. |
|
||||
| Airborne no-op branch | `LiveEntityNetworkUpdateController.cs:1994-1998` / `:2273-2280` | Not a writer at all — it writes nothing but `CellId` + `LastServerPos` (AP-135). It is a *contributor* (see below), not the blip. |
|
||||
| Teleport / `ForcePosition` | `RemoteTeleportController` | Ruled out: requires a bumped teleport/force-position sequence, which ACE does not emit for ordinary sliding movement. |
|
||||
| `InterpolationManager` 100 m autonomy blip | — | Ruled out at this distance. |
|
||||
|
||||
### The un-established half: what the wire says during the slide
|
||||
|
||||
There are **two** shapes of the same symptom, and code alone cannot tell them
|
||||
apart:
|
||||
|
||||
- **Shape A** — ACE reports `IsGrounded == false` throughout the retail
|
||||
sender's slide. ACE derives that flag from its *own* server-side physics:
|
||||
`references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs:72-73`,
|
||||
`if ((PhysicsObj.TransientState & TransientStateFlags.OnWalkable) != 0) flags |= PositionFlags.IsGrounded;`.
|
||||
A steep roof is not walkable, so if ACE's server physics classifies it the
|
||||
same way retail does, the whole slide arrives with the bit clear. Every one of
|
||||
those packets classifies `NoPositionOperation`
|
||||
(`RuntimeAuthoritativePositionRouteClassifier.cs:422-443`) and acdream writes
|
||||
**nothing** — not even the interpolation queue. The remote is frozen for the
|
||||
entire slide, and the first `IsGrounded == true` packet after the sender
|
||||
reaches walkable ground fires the 4 m snap. Visible as: *land, total
|
||||
stillness, one large blip.*
|
||||
- **Shape B** — ACE reports `IsGrounded == true` throughout. Each packet
|
||||
classifies `Interpolate` and feeds the queue. The body still cannot move (§1),
|
||||
so **Candidate 2 fires first**, at ~0.33 s, snapping to the tail waypoint;
|
||||
Candidate 1 would only get its turn if the queue were empty. Visible as:
|
||||
*land, ~third of a second, blip.*
|
||||
|
||||
Note that in **Shape A** the queue is never fed at all (the `NoPositionOperation`
|
||||
branch writes nothing), so Candidate 2 cannot fire — the blip must be
|
||||
Candidate 1, on the first grounded packet after the sender reaches walkable
|
||||
ground. **The two shapes therefore have different blip producers**, which is
|
||||
precisely why the capture is worth taking. This is **NOT ESTABLISHED**.
|
||||
|
||||
One further §1 consequence worth naming here: retail's
|
||||
`InterpolationManager::adjust_offset` **@0x00555d30** gates its entire body on
|
||||
`physics_obj->transient_state & 1` (CONTACT_TS) at 0x00555D52 — a remote in free
|
||||
fall gets no interpolation correction at all. acdream ports that gate
|
||||
(`InterpolationManager.cs:319-321`, `:346-349`) and the remote tick passes
|
||||
`inContact: rm.Body.InContact` (`RuntimeRemotePhysicsUpdater.cs:269`, `:306`) —
|
||||
but Link 1 forces `Contact` true unconditionally, so the gate never engages.
|
||||
Another correct port defeated by the same forged input.
|
||||
|
||||
### Do existing probes distinguish them? No.
|
||||
|
||||
- `ACDREAM_PROBE_REMOTE_LANDING` (`PhysicsDiagnostics.cs:218-265`) fires only at
|
||||
the two landing *edges*. It logs `contact`, `onWalkable`, `gravitySet`,
|
||||
`resolveIsOnGround` — genuinely useful for confirming Links 3 and 4 at the
|
||||
landing instant, but it emits nothing during the slide window and nothing at
|
||||
the snap. It cannot see the wire bit or the classification.
|
||||
- `ACDREAM_PROBE_RESOLVE` (`PhysicsEngine.cs:2640`) logs
|
||||
`groundedIn / cp / hit / walkable` per resolve for every entity. It shows the
|
||||
contact-plane state but not the *result's* `OnWalkable`, not the plane's
|
||||
`Normal.Z`, and not the wire/classification side. It is also ~30 Hz × every
|
||||
entity — impractical for a two-client session.
|
||||
- `ACDREAM_REMOTE_VEL_DIAG` gives `[VEL_DIAG]` pace but no contact or
|
||||
classification data.
|
||||
|
||||
### The probe that would settle it
|
||||
|
||||
One new per-accepted-Position line for remote GUIDs, emitted at the routing
|
||||
site in `LiveEntityNetworkUpdateController` (both arms), plus one per-tick line
|
||||
for the same GUID:
|
||||
|
||||
**`[remote-slide-up]`** — guid, `update.IsGrounded` (the raw wire bit),
|
||||
`earlyRemoteRoute?.Disposition`, `request.PlayerDistance`, `bodyToTarget`,
|
||||
`willBeDrTicked`, `firstUp`, the `ApplyInterpolate` `Action` result
|
||||
(`Snapped`/`Enqueued`), `rm.Airborne`, `Body.TransientState` Contact/OnWalkable,
|
||||
`Body.State & Gravity`, `Body.Velocity`, `Body.ContactPlaneValid`,
|
||||
`Body.ContactPlane.Normal.Z`, wire `worldPos`, `Body.Position`, plus
|
||||
`Interp` queue depth and `_failCount` (needed to tell Candidate 1 from
|
||||
Candidate 2).
|
||||
|
||||
**`[remote-slide-tick]`** — guid, `resolveResult.InContact`,
|
||||
`resolveResult.OnWalkable`, `resolveResult.IsOnGround`,
|
||||
`Body.ContactPlane.Normal.Z`, `Body.Velocity`, `Body.Acceleration`, the
|
||||
pre/post-integrate positions, and whether the body actually moved this tick.
|
||||
Rate-limit to remotes whose `ContactPlaneValid && ContactPlane.Normal.Z <
|
||||
PhysicsGlobals.FloorZ` (i.e. only while standing on something steep) so the
|
||||
volume stays usable in a live two-client run.
|
||||
|
||||
That single capture answers: which shape, which snap site, and whether the
|
||||
contact plane the sweep finds on the roof is actually steep.
|
||||
|
||||
---
|
||||
|
||||
## 3. What retail does — verified directly against the pseudo-C
|
||||
|
||||
All citations independently read out of
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt` for this report.
|
||||
|
||||
### 3.1 Retail's observer runs full physics for *every* object — no player fork
|
||||
|
||||
`CPhysics::UseTime` **@0x00509950** (pseudo-C :271481) iterates a
|
||||
`LongHashIter<CPhysicsObj>` over the whole physics-object hash and calls
|
||||
`update_object` on each entry (pseudo-C :271639-271650):
|
||||
|
||||
```
|
||||
005099e0 class HashBaseData<unsigned long>* curPtr_ = iter->curPtr_;
|
||||
005099e5 CPhysicsObj::update_object(curPtr_);
|
||||
005099ed if (curPtr_ == this->player)
|
||||
005099f2 SmartBox::PlayerPhysicsUpdatedCallback(this->smartbox);
|
||||
005099fa HashBaseIter<unsigned long>::Next(this->iter);
|
||||
```
|
||||
|
||||
The `curPtr_ == this->player` test only fires an extra callback. There is **no
|
||||
local-vs-remote fork on the update itself**.
|
||||
|
||||
`CPhysicsObj::update_object` **@0x00515d10** (pseudo-C :283950-284055) has
|
||||
exactly one early-return gate (:283957):
|
||||
|
||||
```
|
||||
00515d40 if ((this_3->parent != 0 || (this_3->cell == 0 || (this_3->state & 0x1000000) != 0)))
|
||||
00515eeb this_3->transient_state &= 0xffffff7f; // clear ACTIVE
|
||||
00515ef5 return;
|
||||
```
|
||||
|
||||
`0x01000000` is `FROZEN_PS` (ACE `PhysicsState.Frozen = 0x01000000`,
|
||||
`references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:32`). **There is no
|
||||
`is_player`, `autonomous`, `server_controlled`, or `MOVEMENT_LOCKED` gate.**
|
||||
|
||||
`0x01000000` is `FROZEN_PS` (retail header `acclient.h:2841`, `enum
|
||||
PhysicsState`; cross-checked against ACE
|
||||
`references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:32`).
|
||||
|
||||
The rest is the object clock: `player_distance` is computed (:283963-283976,
|
||||
this is retail's own `player_distance` field, the one
|
||||
`MoveOrTeleport` later reads); `set_active(1)` when within 96 m or when the
|
||||
object has no part array (:283983-283988); then dt gates — `< 0.0002` return,
|
||||
`> 2.0` discard, then the MaxQuantum subdivision loop into
|
||||
`UpdateObjectInternal` (:284035-284053). The 96 m test is a **distance LOD that
|
||||
applies to the local player identically**, not an identity fork.
|
||||
|
||||
The only autonomy-flavoured field on `CPhysicsObj` is
|
||||
`last_move_was_autonomous`, written at 0x00514FE9 in `set_description` from
|
||||
`PhysicsDesc::get_autonomous_movement` and read by
|
||||
`CPhysicsObj::movement_is_autonomous` **@0x0050eb30** — whose only four callers
|
||||
are in `CMotionInterp::apply_raw_movement`/`apply_current_movement`
|
||||
(@0x0052888D, @0x005288ED, @0x0052894D, @0x00528991), i.e. deciding whether to
|
||||
*send* movement upstream. **It is never read by the physics tick.**
|
||||
|
||||
`UpdateObjectInternal` **@0x005156B0** (pseudo-C :283611) and
|
||||
`UpdatePositionInternal` **@0x00512C30** (:280817) likewise contain no identity
|
||||
fork. `UpdatePositionInternal`'s only state-dependent branch of interest is
|
||||
`transient_state & 2` (ON_WALKABLE_TS, :280836), which decides whether the
|
||||
animation root-frame delta is scaled by `m_scale` or by zero — the branch
|
||||
acdream mirrors at `RuntimeRemotePhysicsUpdater.cs:133-135`, except that
|
||||
acdream keys it on the client `rm.Airborne` bool instead of the transient.
|
||||
|
||||
**Answer: a retail observer runs the complete transition/slide for a remote,
|
||||
identically to the local player.**
|
||||
|
||||
### 3.2 `MoveOrTeleport` returning 0 does not suspend that simulation
|
||||
|
||||
`CPhysicsObj::MoveOrTeleport` **@0x00516330** (pseudo-C :284304-284366) —
|
||||
verified verbatim:
|
||||
|
||||
- `0x0051638E` — `if (arg4 != 0)` guards the entire near/far branch.
|
||||
- `0x0051636D` — `return 0` when it does not. Nothing is written.
|
||||
- `0x005163AF` — `InterpolateTo(this_1, arg2, IsMovingTo(this_1))` then
|
||||
`return 1` @0x005163BE, on `player_distance < 96f`.
|
||||
- `0x00516386` — cell-0/teleport branch, `teleport_hook` + flags-`0x1012`
|
||||
`SetPosition`, `return 1` @0x00516438.
|
||||
|
||||
Nothing on the `return 0` path touches `update_time`, `FROZEN_PS`, `parent`,
|
||||
or `cell` — the three things `update_object` gates on. **The object keeps being
|
||||
ticked by `CPhysics::UseTime` at full rate.** This is the single most important
|
||||
retail fact for Bug B: retail's airborne no-op is safe *because* retail is
|
||||
simulating the object locally. acdream copied the no-op without the simulation.
|
||||
|
||||
The caller confirms the same: `SmartBox::HandleReceivedPosition` **@0x00453FD0**,
|
||||
remote branch (pseudo-C :92995), is
|
||||
`if (MoveOrTeleport(...) != 0) { …ConstrainTo @0x00454272 }` and then returns.
|
||||
On the zero return it has done nothing but `unset_parent` @0x00454129 and the
|
||||
`!HasAnims`-gated `SetPlacementFrame` @0x00454142 — neither of which touches
|
||||
position, velocity, `FROZEN_PS`, or `ACTIVE_TS`.
|
||||
|
||||
Incidental but worth recording: **`MoveOrTeleport` never references `arg5`**,
|
||||
the wire velocity vector. Retail discards the broadcast velocity for remotes
|
||||
entirely and relies on its own simulation — which is exactly the load acdream's
|
||||
`RuntimeRemotePhysicsUpdater.cs:169` cannot carry.
|
||||
|
||||
### 3.3 Retail derives `on_walkable` from the contact plane
|
||||
|
||||
`CPhysicsObj::SetPositionInternal` **@0x00515330**, pseudo-C :283481-283509
|
||||
(read out directly):
|
||||
|
||||
```
|
||||
00515430 if (arg2->collision_info.contact_plane_valid == 0)
|
||||
00515437 eax_7 = (transient_state_1 & 0xfffffffe); // clear CONTACT_TS (0x1)
|
||||
00515430 else
|
||||
00515432 eax_7 = (transient_state_1 | 1); // set CONTACT_TS
|
||||
0051543c this->transient_state = eax_7;
|
||||
00515442 CPhysicsObj::calc_acceleration(this);
|
||||
...
|
||||
00515465 if ((eax_9 & 1) == 0) // not in contact
|
||||
0051549f this->transient_state = (eax_9 & 0xfffffffd); // clear ON_WALKABLE (0x2)
|
||||
005154a5 if ((eax_9 & 2) != 0) MovementManager::LeaveGround(...)
|
||||
00515465 else
|
||||
00515467 long double x87_r7_1 = this->contact_plane.N.z;
|
||||
0051546d long double temp1_1 = PhysicsGlobals::floor_z;
|
||||
00515478 if (contact_plane.N.z < floor_z)
|
||||
0051548e CPhysicsObj::set_on_walkable(this, 0);
|
||||
00515478 else
|
||||
00515482 CPhysicsObj::set_on_walkable(this, 1);
|
||||
```
|
||||
|
||||
Contact and on-walkable are **two independent facts**. A steep roof is Contact
|
||||
and not on-walkable. acdream ports this correctly in
|
||||
`PhysicsObjUpdate.ApplySetPositionContact` (`:30-56`) and
|
||||
`IsWalkableContact` (`:20-21`) — it simply never calls them on the visible
|
||||
remote path (Link 4).
|
||||
|
||||
### 3.4 What actually makes retail slide on a steep contact
|
||||
|
||||
Three pieces, all present and correct in acdream's Core — none of them reachable
|
||||
by a grounded remote:
|
||||
|
||||
1. **Gravity survives.** `CPhysicsObj::calc_acceleration` **@0x00510950**
|
||||
(pseudo-C :278533; acdream's port is `PhysicsBody.cs:503-518`) zeroes
|
||||
acceleration only when `CONTACT && ON_WALKABLE && !SLEDDING` (0x0051096B). On
|
||||
a steep contact `ON_WALKABLE` is clear, so it falls through to the
|
||||
`state & GRAVITY_PS (0x400)` test at 0x005109F4 →
|
||||
`acceleration = (0, 0, PhysicsGlobals::gravity = -9.80000019)`.
|
||||
**`GRAVITY_PS` is on in the `CPhysicsObj` constructor** — `state = 0x400C08`
|
||||
at 0x00512508 (EDGE_SLIDE | LIGHTING_ON | GRAVITY | REPORT_COLLISIONS) — and
|
||||
is thereafter set wholesale from the wire by `set_description`'s
|
||||
`set_state(this, desc->state, 1)` @0x00514F40. `set_state` @0x00514DD0
|
||||
post-processes only lighting / nodraw / hidden; **it never masks GRAVITY**.
|
||||
Retail never toggles the bit on a landing.
|
||||
2. **Friction does not engage.** `CPhysicsObj::calc_friction` **@0x0050ee70**
|
||||
(pseudo-C :276694) opens with `if ((this->transient_state & 2) != 0)` at
|
||||
0x0050EE7D — the *entire* function body, including the
|
||||
`v -= (v·N)N` into-plane removal and the `pow(1-friction, dt)` damping, is
|
||||
inside that `if`. On a non-walkable contact it returns immediately: no
|
||||
friction, and the into-surface component is never projected out. acdream's
|
||||
port has the identical gate (`PhysicsBody.cs:693-696`).
|
||||
3. **The sweep projects the motion along the plane** — `CTransition` /
|
||||
`SlideSphere` @0x00537440 and the `SetPositionInternal` sliding-normal tail
|
||||
at 0x005154c2-0x005154e8.
|
||||
|
||||
`UpdatePhysicsInternal` then integrates: `Velocity += Acceleration * dt`
|
||||
unconditionally (acdream `PhysicsBody.cs:771`). That is the slide.
|
||||
|
||||
### 3.5 Retail's interpolation is a per-frame *offset*, not the motion
|
||||
|
||||
`PositionManager::adjust_offset` **@0x00555190** (pseudo-C :352090-352115)
|
||||
chains `InterpolationManager::adjust_offset` → `StickyManager::adjust_offset`
|
||||
→ `ConstraintManager::adjust_offset` into the same `Frame` that
|
||||
`UpdatePositionInternal` then sends through the transition sweep. In retail the
|
||||
interpolation nudges an object that is *already* moving under its own physics.
|
||||
In acdream the interpolation catch-up is the object's *entire* motion
|
||||
(`RemoteMotionCombiner.ComposeOffset`, `:40-76`), because Links 1-3 removed
|
||||
everything else.
|
||||
|
||||
---
|
||||
|
||||
## 4. Is the roof even walkable? — the classification agrees; the consumer does not
|
||||
|
||||
**acdream's classifier agrees with retail.** `PhysicsObjUpdate.IsWalkableContact`
|
||||
(`:20-21`) is `inContact && contactNormal.Z >= PhysicsGlobals.FloorZ` — the same
|
||||
predicate as retail's `contact_plane.N.z < floor_z` test above, and the same as
|
||||
retail's standalone `CPhysicsObj::is_valid_walkable` **@0x0050f530** (pseudo-C
|
||||
:277180), which is literally `return N.z < floor_z ? 0 : 1`.
|
||||
|
||||
**Constant check — with a caveat.** acdream `TransitionTypes.cs:1255` carries
|
||||
`FloorZ = 0.6642f`, while `src/AcDream.Core/Rendering/Wb/TerrainUtils.cs:20`
|
||||
carries `0.66417414618662751f`. Two copies of the same constant in our own tree,
|
||||
differing by 2.6e-5 (about 0.002° of slope). Not load-bearing for Bug B, but it
|
||||
is an unexplained internal divergence and should be unified on the longer value.
|
||||
|
||||
**The literal 0.66417414 does NOT appear anywhere in the pseudo-C** — grep
|
||||
returns nothing. `PhysicsGlobals::floor_z` lives at 0x008EDE5C and is computed
|
||||
at static-init in `$E73` **@0x0070d920** (pseudo-C :805181), which Binary Ninja
|
||||
renders as `floor_z = __fcos(3437.7467707849391)`. That rendering is a BN
|
||||
artifact of the classic elided-numerator kind (`3437.74677` is
|
||||
arcminutes-per-radian, so the real source is `cos(<arcmin> / 3437.7467…)`; BN
|
||||
dropped the numerator, exactly the class of defect
|
||||
`claude-memory/feedback_bn_decomp_field_names.md` warns about). The *value* is
|
||||
corroborated by WorldBuilder and ACE agreeing on 0.66417414618662751, but a
|
||||
byte decode of 0x0070D920 would be needed to pin it from the binary — see
|
||||
NOT ESTABLISHED #6.
|
||||
|
||||
So the "acdream thinks it's walkable, retail doesn't" hypothesis is **half
|
||||
true, and its cause is not the threshold**. acdream's *sweep* would classify
|
||||
the roof exactly as retail does. The forged `OnWalkable` at
|
||||
`RuntimeRemotePhysicsUpdater.cs:150-154` overwrites that answer before the
|
||||
sweep ever runs, and `Tick` discards the answer the sweep returns anyway
|
||||
(Link 4). This is a plumbing defect, not a geometry or threshold defect.
|
||||
|
||||
**NOT ESTABLISHED:** whether the specific house roof in the user's test
|
||||
actually produces a contact plane with `Normal.Z < 0.6642` in acdream's
|
||||
collision data. Buildings are GfxObj/Setup collision, and no capture of the
|
||||
contact plane on that roof exists. The `[remote-slide-tick]` probe's
|
||||
`ContactPlane.Normal.Z` field settles it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Is it in the register / ISSUES already?
|
||||
|
||||
**Yes, partially — three rows and one issue, none of them complete.**
|
||||
|
||||
| Record | What it covers | Gap |
|
||||
|---|---|---|
|
||||
| `docs/ISSUES.md` **#32** (2026-08-04 addendum, ~:9562) | Bug B named, root-caused to the *landing block's* unconditional `OnWalkable`; cites `SetPositionInternal` @0x00515330 :283501-283509 correctly | Does not name `RuntimeRemotePhysicsUpdater.cs:150-154` (the dominant per-tick force), does not name `:169` (velocity zeroing), and treats Link 3 (Gravity) as a Bug A concern only. The stated fix target is therefore insufficient. |
|
||||
| register **AP-87** (`:242`) | The 4 m snap; its risk column *already predicted this exact symptom* and was updated 2026-08-04 with the live observation | Correct and current. AP-87 is the blip's mechanism, not its cause — do not "fix" AP-87. |
|
||||
| register **AP-81** (`:235`) | Remote VectorUpdate's non-retail Airborne/Gravity dance; risk column literally says "grounded remotes carry a non-retail state word" | Its file:line column lists only the VectorUpdate handler and the landing blocks. It does **not** list `RuntimeRemotePhysicsUpdater.cs:150-154`/`:169` — the per-tick unconditional force and velocity zeroing have **no register row of their own** and are not covered by AP-81's cited sites. Register rule 1 violation; the row needs extending in whichever commit next touches that file. |
|
||||
| register **AD-10** (`:122`) | Remote slope projection is terrain-normal-only (`RemoteMotionCombiner.ComposeOffset :65-73`, `ComputeOffset :163-168`), cannot see building/EnvCell geometry; already amended 2026-08-04 to name Bug B | Correct. Confirms that even a corrected `OnWalkable` gets no help from this path on a house roof. |
|
||||
|
||||
**Verified `#32` is NOT a route-4a regression, independently:** the landing
|
||||
block's `TransientState |= Contact | OnWalkable` and the per-tick force at
|
||||
`:150-154` both predate C4. The per-tick force carries the comment "Forces
|
||||
OnWalkable + Contact so the gate in `apply_current_movement` always succeeds"
|
||||
(`:138-140`) — a #184-Slice-2b-era construct, unrelated to route 4a. Do not
|
||||
revert `44830a0e` or `7f1c1f5a`.
|
||||
|
||||
**DO-NOT-RETRY check** (`claude-memory/project_physics_collision_digest.md`):
|
||||
the proposal in §6 does not appear in any DO-NOT-RETRY table. Closest
|
||||
neighbours, all distinct: the AD-25 landing `Velocity.Z = 0` hand-zero
|
||||
(:145-151) — this proposal *removes* a velocity zeroing rather than adding one,
|
||||
and does not touch the landing reflect; "do not seed transition contact from a
|
||||
caller's `isOnGround` bool when a body is present" (:149-150) — this proposal
|
||||
moves the remote *toward* body-derived contact, which is the same direction;
|
||||
#269's slope-slide residual (:152-160) — a *local-player* decay-curve question
|
||||
whose code is byte-exonerated, unrelated to the remote plumbing here.
|
||||
|
||||
---
|
||||
|
||||
## 6. Proposed fix
|
||||
|
||||
### The one-line summary
|
||||
|
||||
The remote tick already runs retail's sweep and the sweep already computes
|
||||
retail's answer. **Stop forging the inputs and start consuming the outputs** —
|
||||
i.e. make the visible remote path use the same
|
||||
`PhysicsObjUpdate.CommitSetPositionTransition` seam that `TickHidden` and every
|
||||
other body in the codebase already use.
|
||||
|
||||
### Concrete targets
|
||||
|
||||
1. **`src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:150-154`** —
|
||||
delete the unconditional `Contact | OnWalkable` force from the grounded
|
||||
branch. Keep `Active`. Retail's equivalent state is written only by
|
||||
`SetPositionInternal` from the contact plane
|
||||
(`@0x00515330`, 0x00515430 / 0x00515465-0x0051548e).
|
||||
2. **`RuntimeRemotePhysicsUpdater.cs:420-477`** — replace the bare
|
||||
`HandleAllCollisions` call with `PhysicsObjUpdate.CommitSetPositionTransition
|
||||
(body, resolveResult.InContact, resolveResult.OnWalkable, …, previousContact,
|
||||
previousOnWalkable, rm.Movement.HitGround, rm.Motion.LeaveGround, isCurrent)`
|
||||
— byte-identical in shape to `TickHidden.cs:778-795`, which already does
|
||||
exactly this. `CommitSetPositionTransition` calls `HandleAllCollisions`
|
||||
itself (`PhysicsObjUpdate.cs:104-110`), so this is a substitution, not an
|
||||
addition. Retail order is `SetPositionInternal` contact/walkable →
|
||||
`HitGround`/`LeaveGround` → `handle_all_collisions` @0x005154FE.
|
||||
3. **`RuntimeRemotePhysicsUpdater.cs:493-495`** — the landing test currently
|
||||
uses `resolveResult.IsOnGround`, which is `inContact || …`
|
||||
(`PhysicsEngine.cs:2660`) and therefore true on a steep roof. It must be
|
||||
`resolveResult.OnWalkable`. Retail's ground edge is `set_on_walkable`, not
|
||||
contact.
|
||||
4. **`RuntimeRemotePhysicsUpdater.cs:169`** — the `Velocity = Zero` must become
|
||||
conditional on the body actually being on walkable ground, or be deleted in
|
||||
favour of letting `calc_friction` (which retail gates on `OnWalkable`,
|
||||
`PhysicsBody.cs:693-696`) do the decay. Deleting it outright is the
|
||||
retail-faithful shape; making it conditional is the smaller step.
|
||||
5. **`RuntimeRemotePhysicsUpdater.cs:571-576` and
|
||||
`LiveEntityNetworkUpdateController.cs:2110-2116`** — stop clearing
|
||||
`PhysicsStateFlags.Gravity`. Retail keeps GRAVITY_PS for the object's whole
|
||||
life and gates gravity acceleration on the Contact transient inside
|
||||
`calc_acceleration` (already correct in `PhysicsBody.cs:505-517`). This
|
||||
retires the bulk of register row **AP-81** and is shared with **Bug A**'s H1.
|
||||
6. **`LiveEntityNetworkUpdateController.cs:2023-2024`** — the landing block's
|
||||
force, the site ISSUES.md #32 names. Necessary but, alone, useless: `:150-154`
|
||||
re-asserts it on the next tick.
|
||||
|
||||
7. **Register bookkeeping, same commit.** `AP-81`'s file:line column must gain
|
||||
`RuntimeRemotePhysicsUpdater.cs:150-154` and `:169` (§5). If items 1-5 land,
|
||||
AP-81 is largely retired and the row should be deleted or narrowed in the
|
||||
same commit per the register's rule 1. `AP-87` stays — it is the backstop,
|
||||
not the bug.
|
||||
|
||||
**Explicitly NOT in scope:** `src/AcDream.Core/Physics/InterpolationManager.cs`.
|
||||
Its stall blip (§2 Candidate 2) is a faithful port of retail
|
||||
`InterpolationManager::UseTime` @0x00555f20 and is behaving correctly on a
|
||||
frozen body. Touching it would be a symptom-site guard.
|
||||
|
||||
### Ordering and prerequisites
|
||||
|
||||
Items 1-4 are one coherent change and must land together — any subset leaves
|
||||
either a forged input or a discarded output. Item 5 is separable and is shared
|
||||
with Bug A. Item 6 falls out of item 1 (both sites express the same wrong idea).
|
||||
Item 7 is bookkeeping and rides whichever commit lands 1-4.
|
||||
|
||||
### Blast radius
|
||||
|
||||
- **Every remote — players and NPCs alike.** Slice 2b deliberately collapsed the
|
||||
player/NPC fork (`RuntimeRemotePhysicsUpdater.cs:110-117`); there is one path.
|
||||
A remote will now genuinely fall, slide, and be subject to friction. This is
|
||||
the intent, and it is what makes it risky: **the #184 invisible-but-solid
|
||||
monster lived in this neighbourhood.** AP-87's 4 m snap stays as the backstop
|
||||
and must not be weakened in the same change (AP-137 explicitly warns that
|
||||
weakening it turns the leftover arm into a silent-freeze path).
|
||||
- **NPC free-fall / knockback** — the route 4a acceptance criterion "push a
|
||||
monster off a ledge, confirm it falls and lands without hovering"
|
||||
(`2026-08-03-c4-route-4a-contract.md:174-183`) is directly exercised by items
|
||||
3 and 5 and must be re-run.
|
||||
- **Local player** — untouched. `PlayerMovementController` constructs its body
|
||||
with `State = Gravity | ReportCollisions` permanently
|
||||
(`PlayerMovementController.cs:689`) and already commits contact from the
|
||||
sweep. No shared code changes.
|
||||
- **Projectiles** — untouched. `RuntimeProjectilePhysicsUpdater` has its own
|
||||
stepper and its own state handling.
|
||||
- **Hidden remotes** — untouched; `TickHidden` already does the right thing and
|
||||
is the template.
|
||||
- **Sticky melee (TS-44) and de-overlap (#184/AP-86)** — the shadow-follows-
|
||||
resolved sync at `:618-630` reads `rm.Body.Position` after the resolve; a body
|
||||
that now genuinely moves under gravity will re-flood its shadow more often.
|
||||
Perf note, not correctness, but worth measuring in a packed town.
|
||||
- **The interpolation contact gate goes live.** `InterpolationManager.AdjustOffset`'s
|
||||
`inContact` early-return (`:319-321`, `:346-349` — retail's @0x00555D52
|
||||
CONTACT_TS gate) currently never engages because Link 1 forges the bit. After
|
||||
item 1 it will, and an out-of-contact remote will stop receiving interpolation
|
||||
corrections mid-fall — which is retail-correct, and which also means Candidate 2
|
||||
can no longer accumulate fail counts against a falling body. Expect the
|
||||
observable timing of any residual blip to change.
|
||||
|
||||
### Does it need live cdb evidence rather than a code change?
|
||||
|
||||
**The retail side does not** — §3 establishes retail's behaviour from the
|
||||
pseudo-C conclusively, with no ambiguity requiring a runtime trace. `CPhysics::UseTime`
|
||||
iterating every object, `update_object`'s three-condition gate, `MoveOrTeleport`'s
|
||||
bare `return 0`, and `SetPositionInternal`'s floor_z comparison are all read
|
||||
directly and are unambiguous.
|
||||
|
||||
**The acdream side does**, for one thing only: **which of Shape A / Shape B is
|
||||
happening on the wire**, and whether the roof's contact plane in acdream's own
|
||||
collision data is actually steep (§4's NOT ESTABLISHED). That is an *acdream*
|
||||
capture — the `[remote-slide-up]` / `[remote-slide-tick]` probe of §2 — not a
|
||||
retail cdb attach. Retail cdb is not the right tool here and would not answer
|
||||
either question.
|
||||
|
||||
The honest recommendation: **land the probe, take one two-client capture, then
|
||||
implement items 1-4 together.** Implementing before the capture is defensible
|
||||
given how complete the code-side chain is, but the capture costs one session
|
||||
and tells us whether the wire is *also* starving the path (Shape A), which
|
||||
changes whether the `NoPositionOperation` branch needs its own follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Shared root with Bug A?
|
||||
|
||||
**Partially — one of three links is shared; the rest are not.**
|
||||
|
||||
Shared: **Link 3, the Gravity clear.** Bug A's H1
|
||||
(`2026-08-04-remote-landing-investigation.md:55-88`) is that the Gravity bit is
|
||||
already clear when `HitGround()` runs, so `CMotionInterp::HitGround`'s
|
||||
`state & 0x400` gate silently no-ops and the Falling pose never exits. Bug B's
|
||||
Link 3 is that the same clear removes the gravity acceleration that would drive
|
||||
the slide. **Item 5 of the fix addresses both.**
|
||||
|
||||
Also shared: the **misclassified landing edge** (item 3). If the remote "lands"
|
||||
on a steep roof because `IsOnGround` is contact-derived, it runs the whole
|
||||
landing block — `HitGround`, Gravity clear, pose transition — on a surface
|
||||
retail is still sliding it down. That is a plausible amplifier for Bug A's
|
||||
symptom *specifically in the roof scenario*, though Bug A also occurs on flat
|
||||
ground, where it must have another cause.
|
||||
|
||||
Not shared: Links 1, 2, and 4 (the per-tick force, the velocity zeroing, and
|
||||
the uncommitted sweep result) are pure position/physics and have no animation
|
||||
consequence. Bug A's H2 (no `DefaultSink` bound) and H3 (scheduler-side) have no
|
||||
Bug B analogue.
|
||||
|
||||
---
|
||||
|
||||
## NOT ESTABLISHED
|
||||
|
||||
1. **Which shape (A or B) the wire produces during a retail sender's roof
|
||||
slide** — i.e. whether ACE emits `IsGrounded == false` for the whole slide.
|
||||
Requires the `[remote-slide-up]` capture. Depends on whether ACE's *server-side*
|
||||
physics classifies a house roof as non-walkable, which was not traced.
|
||||
2. **Whether the specific roof produces a contact plane with `Normal.Z <
|
||||
0.6642` in acdream's collision data.** Requires the `[remote-slide-tick]`
|
||||
capture's `ContactPlane.Normal.Z` field.
|
||||
3. **Whether items 1-4 are sufficient** to reproduce retail's slide, or whether
|
||||
the `#173` remote collision-velocity reflect (shipped but its gate folded
|
||||
into the never-run Campaign P matrix scenario 8) and AD-10's terrain-only
|
||||
projection also need work. ISSUES.md #32 already flags both as open
|
||||
dependencies and that assessment is confirmed here.
|
||||
4. **Whether ACE relays a `0xF74E` VectorUpdate at all during a slide.** If it
|
||||
does not, the sender's slide velocity never reaches acdream even in
|
||||
principle, and the local simulation is the only possible source — which
|
||||
strengthens the fix but was not verified against ACE's broadcast conditions.
|
||||
5. **Whether ACE's server-side physics classifies a house roof as
|
||||
non-walkable.** ACE emits `IsGrounded` from its own
|
||||
`PhysicsObj.TransientState & OnWalkable` (`PositionPack.cs:72-73`), so
|
||||
Shape A requires ACE's server physics to agree with retail's `floor_z` test
|
||||
on building geometry. Not traced.
|
||||
6. **`PhysicsGlobals::floor_z`'s exact value from the binary.** The literal is
|
||||
absent from the pseudo-C and its static-init is a BN-mangled `fcos`
|
||||
(§4). The value is corroborated by two independent references but not by a
|
||||
primary byte decode of 0x0070D920.
|
||||
7. **The polarity of `MoveOrTeleport`'s `player_distance` vs `96.0f` compare**
|
||||
at 0x00516393 — BN emits `bool p = unimplemented {test ah, 0x5}` and its
|
||||
synthesized `fcom` operand ordering is inconsistent across this dump. Nothing
|
||||
in this diagnosis depends on it: both arms `return 1` and both leave the
|
||||
object in the simulation set, and the observer in the user's test is far
|
||||
inside 96 m either way.
|
||||
Loading…
Add table
Add a link
Reference in a new issue