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>
38 KiB
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:
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:
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:
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:
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:
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 == falsethroughout 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 classifiesNoPositionOperation(RuntimeAuthoritativePositionRouteClassifier.cs:422-443) and acdream writes nothing — not even the interpolation queue. The remote is frozen for the entire slide, and the firstIsGrounded == truepacket after the sender reaches walkable ground fires the 4 m snap. Visible as: land, total stillness, one large blip. - Shape B — ACE reports
IsGrounded == truethroughout. Each packet classifiesInterpolateand 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 logscontact,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) logsgroundedIn / cp / hit / walkableper resolve for every entity. It shows the contact-plane state but not the result'sOnWalkable, not the plane'sNormal.Z, and not the wire/classification side. It is also ~30 Hz × every entity — impractical for a two-client session.ACDREAM_REMOTE_VEL_DIAGgives[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 0when it does not. Nothing is written.0x005163AF—InterpolateTo(this_1, arg2, IsMovingTo(this_1))thenreturn 1@0x005163BE, onplayer_distance < 96f.0x00516386— cell-0/teleport branch,teleport_hook+ flags-0x1012SetPosition,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:
- Gravity survives.
CPhysicsObj::calc_acceleration@0x00510950 (pseudo-C :278533; acdream's port isPhysicsBody.cs:503-518) zeroes acceleration only whenCONTACT && ON_WALKABLE && !SLEDDING(0x0051096B). On a steep contactON_WALKABLEis clear, so it falls through to thestate & GRAVITY_PS (0x400)test at 0x005109F4 →acceleration = (0, 0, PhysicsGlobals::gravity = -9.80000019).GRAVITY_PSis on in theCPhysicsObjconstructor —state = 0x400C08at 0x00512508 (EDGE_SLIDE | LIGHTING_ON | GRAVITY | REPORT_COLLISIONS) — and is thereafter set wholesale from the wire byset_description'sset_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. - Friction does not engage.
CPhysicsObj::calc_friction@0x0050ee70 (pseudo-C :276694) opens withif ((this->transient_state & 2) != 0)at 0x0050EE7D — the entire function body, including thev -= (v·N)Ninto-plane removal and thepow(1-friction, dt)damping, is inside thatif. 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). - The sweep projects the motion along the plane —
CTransition/SlideSphere@0x00537440 and theSetPositionInternalsliding-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
-
src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:150-154— delete the unconditionalContact | OnWalkableforce from the grounded branch. KeepActive. Retail's equivalent state is written only bySetPositionInternalfrom the contact plane (@0x00515330, 0x00515430 / 0x00515465-0x0051548e). -
RuntimeRemotePhysicsUpdater.cs:420-477— replace the bareHandleAllCollisionscall withPhysicsObjUpdate.CommitSetPositionTransition (body, resolveResult.InContact, resolveResult.OnWalkable, …, previousContact, previousOnWalkable, rm.Movement.HitGround, rm.Motion.LeaveGround, isCurrent)— byte-identical in shape toTickHidden.cs:778-795, which already does exactly this.CommitSetPositionTransitioncallsHandleAllCollisionsitself (PhysicsObjUpdate.cs:104-110), so this is a substitution, not an addition. Retail order isSetPositionInternalcontact/walkable →HitGround/LeaveGround→handle_all_collisions@0x005154FE. -
RuntimeRemotePhysicsUpdater.cs:493-495— the landing test currently usesresolveResult.IsOnGround, which isinContact || …(PhysicsEngine.cs:2660) and therefore true on a steep roof. It must beresolveResult.OnWalkable. Retail's ground edge isset_on_walkable, not contact. -
RuntimeRemotePhysicsUpdater.cs:169— theVelocity = Zeromust become conditional on the body actually being on walkable ground, or be deleted in favour of lettingcalc_friction(which retail gates onOnWalkable,PhysicsBody.cs:693-696) do the decay. Deleting it outright is the retail-faithful shape; making it conditional is the smaller step. -
RuntimeRemotePhysicsUpdater.cs:571-576andLiveEntityNetworkUpdateController.cs:2110-2116— stop clearingPhysicsStateFlags.Gravity. Retail keeps GRAVITY_PS for the object's whole life and gates gravity acceleration on the Contact transient insidecalc_acceleration(already correct inPhysicsBody.cs:505-517). This retires the bulk of register row AP-81 and is shared with Bug A's H1. -
LiveEntityNetworkUpdateController.cs:2023-2024— the landing block's force, the site ISSUES.md #32 names. Necessary but, alone, useless::150-154re-asserts it on the next tick. -
Register bookkeeping, same commit.
AP-81's file:line column must gainRuntimeRemotePhysicsUpdater.cs:150-154and: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-87stays — 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.
PlayerMovementControllerconstructs its body withState = Gravity | ReportCollisionspermanently (PlayerMovementController.cs:689) and already commits contact from the sweep. No shared code changes. - Projectiles — untouched.
RuntimeProjectilePhysicsUpdaterhas its own stepper and its own state handling. - Hidden remotes — untouched;
TickHiddenalready 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-630readsrm.Body.Positionafter 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'sinContactearly-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
- Which shape (A or B) the wire produces during a retail sender's roof
slide — i.e. whether ACE emits
IsGrounded == falsefor 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. - Whether the specific roof produces a contact plane with
Normal.Z < 0.6642in acdream's collision data. Requires the[remote-slide-tick]capture'sContactPlane.Normal.Zfield. - Whether items 1-4 are sufficient to reproduce retail's slide, or whether
the
#173remote 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. - Whether ACE relays a
0xF74EVectorUpdate 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. - Whether ACE's server-side physics classifies a house roof as
non-walkable. ACE emits
IsGroundedfrom its ownPhysicsObj.TransientState & OnWalkable(PositionPack.cs:72-73), so Shape A requires ACE's server physics to agree with retail'sfloor_ztest on building geometry. Not traced. PhysicsGlobals::floor_z's exact value from the binary. The literal is absent from the pseudo-C and its static-init is a BN-mangledfcos(§4). The value is corroborated by two independent references but not by a primary byte decode of 0x0070D920.- The polarity of
MoveOrTeleport'splayer_distancevs96.0fcompare at 0x00516393 — BN emitsbool p = unimplemented {test ah, 0x5}and its synthesizedfcomoperand ordering is inconsistent across this dump. Nothing in this diagnosis depends on it: both armsreturn 1and both leave the object in the simulation set, and the observer in the user's test is far inside 96 m either way.