Three Option-A patches addressing visual issues from the L.3.1+L.3.2
remote-entity motion path (gated by ACDREAM_INTERP_MANAGER=1):
1. Landing fallback. ACE doesn't always send IsGrounded=true on the
landing frame, so airborne remotes kept falling under gravity and
visually "disappeared into the ground" until the next non-stop UP
forced a re-snap. Track the most recent server-broadcast Z on every
UP (including mid-arc airborne ones) and, in TickAnimations, snap
the body back up + clear airborne when its predicted Z drops more
than 0.5 m below that floor.
2. TurnLeft omega sign. The synthesize-omega fallback in
AnimationSequencer (used when MotionData ships without HasOmega)
had case 0x0E using zomega = +(pi/2) * adjustedSpeed, but
adjust_motion above already remapped 0x0E to 0x0D with
adjustedSpeed = -speedMod. The double-negate produced -Z (clockwise
= right) for both turn directions, matching the reported "turning
left animates as turning right". Use the same -(pi/2) * adjustedSpeed
formula as case 0x0D so the negation lands the result on +Z (CCW).
3. Velocity diagnostic. New env var ACDREAM_REMOTE_VEL_DIAG=1 prints
one line per moving remote per ~2 seconds comparing the sequencer's
CurrentVelocity to the server's effective broadcast pace
((LastServerPos - PrevServerPos) / dt). Lets us measure the
speed-overshoot ratio that produces the residual 1-Hz blippiness
before tuning a fix.
Refs Phase L.3.1+L.3.2 spec at
docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Visual verification (Task 4) revealed two missing pieces from the
retail per-frame tick port (acclient!CPhysicsObj::update_object
@ 0x00513730):
1. body.calc_acceleration() must run BEFORE UpdatePhysicsInternal so
gravity (set via PhysicsStateFlags.Gravity in OnLiveVectorUpdated)
actually decays jump velocity. Without it body.Acceleration stays
stale or zero → endless rise on jumps.
2. sequencer.CurrentOmega must be applied to body.Orientation per frame.
Retail's TurnRight/TurnLeft cycles bake angular velocity that drives
smooth rotation between UPs; we were only snapping orientation on
UP receipt (~1 Hz), producing visible chop on turning remotes.
Both fixes are part of the retail tick we already started porting in
PositionManager — just missing pieces.
Speed-overshoot bug (sequencer.CurrentVelocity > server's actual
broadcast pace) is still being investigated in a follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PositionFlags.IsGrounded (0x04) was already parsed by UpdatePosition
but not exposed through the Parsed record or EntityPositionUpdate.
Adds the bool field to both records so OnLivePositionUpdated can
consume it for retail-faithful MoveOrTeleport routing
(acclient @ 0x00516330: has_contact=false → no-op during airborne arc).
Consumed in subsequent task (L.3.1+L.3.2 Task 3).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Round 1 (5154a3e) tried to fix:
- heading locked → orientation snap-on-receipt (good idea)
- endless jump → landing detector via UP-with-zero-velocity (didn't work; ACE sends non-zero velocity through arc)
Round 2 (f199a6a) tried to fix:
- chop at 1 Hz → seed body.Velocity from update.Velocity for between-UP extrapolation (didn't help)
- endless jump → reported-Z-near-body-Z + falling-velocity heuristic (didn't catch reliably)
The actual problem was scoping: L.3.1's "InterpolationManager only" cannot
produce smooth motion. Retail combines animation root motion (L.3.2 /
PositionManager) + InterpolationManager corrections. Both halves are
required for "remotes look smooth".
Reverting to e08accf (Task 6 — VectorUpdate.Omega). The next commits
will properly port PositionManager + plumb IsGrounded through the wire
parser, replacing L.3.1-only with L.3.1+L.3.2 combined per the
revised spec.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Round 2 fix for two visual bugs that survived commit 5154a3e:
Bug 1 (chop at 1 Hz UP cadence): Round 1 zeroed body.Velocity each
tick on grounded remotes, leaving AdjustOffset as the sole motion
source. AdjustOffset catches up in ~150 ms then sits idle until the
next UP at 1 Hz, producing visible "updates every 1 second" stepping.
Root cause: retail achieves smoothness via animation root motion +
AdjustOffset *corrections*; we only ported corrections (root motion
is Phase L.3.2 / PositionManager). Workaround for L.3.1: seed
body.Velocity from update.Velocity on every grounded UP so
UpdatePhysicsInternal integrates position += vel*dt between UPs,
with the queue providing corrective patches via AdjustOffset.
Bug 2 (endless jump): Round 1 tried to detect landing via "UP arrives
during airborne with no velocity" but ACE keeps sending non-zero
velocity through the arc, so the detector never fired. Fix: stop
maintaining a local "predicted arc". Server is authoritative for
airborne position too -- hard-snap from each UP during airborne;
body.Velocity (set by OnLiveVectorUpdated) integrates between UPs
for smoothing. Landing detected via reported-Z-near-body-Z + falling/
settled velocity heuristic (more reliable than the velocity-zero
test).
Per-frame tick: removed the !rm.Airborne velocity clamp from Round 1.
OnLivePositionUpdated now owns velocity policy; per-tick just
integrates whatever is set.
Both deviations from retail decomp are documented in source comments
and slated for L.3.2 (PositionManager) cleanup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Visual verification (Task 7) revealed two bugs in the new env-var
gated path:
1. Heading locked at login direction. Cause: AdjustOffset returns
position delta only; the dist≤96 enqueue branch never updated
body.Orientation. Fix: apply orientation unconditionally on every
UpdatePosition (snap-on-receipt). Position lerps via queue.
2. Endless jumping. Cause: (a) body.Velocity persisted forever
after arc landed because apply_current_movement no longer ran;
(b) UpdatePositions during the arc were enqueued, fighting the
gravity sim. Fix: skip enqueue when rm.Airborne (mirrors retail
MoveOrTeleport has_contact=false → no-op); zero non-airborne
body.Velocity each tick (mirrors legacy apply_current_movement);
detect landed when receiving UpdatePosition while airborne with
no/zero velocity.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
VectorUpdate.Omega was parsed by WorldSession but never written to
the remote body's Omega field, leaving remote jumping/turning arcs
flat. Apply it alongside the existing Velocity assignment.
Mirrors retail SmartBox::DoVectorUpdate (acclient @ 0x004521C0)
which calls both CPhysicsObj::set_velocity AND CPhysicsObj::set_omega.
Same 4 pre-existing test failures, no regression.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps the existing legacy per-frame remote tick (apply_current_movement
+ force-OnWalkable + Euler-extrapolate) in ACDREAM_INTERP_MANAGER=1
env-var guard. When set:
- if Interp.IsActive: rm.Body.Position += Interp.AdjustOffset(dt, pos, maxSpeed)
- still call body.UpdatePhysicsInternal so airborne arcs (gravity)
continue to integrate via the OnLiveVectorUpdated-set velocity.
When env-var unset (default), legacy path runs unchanged.
Mirrors retail's per-tick CPhysicsObj::UpdateObjectInternal (acclient
@ 0x00513730) which calls InterpolationManager::adjust_offset
(@ 0x00555D30) every frame.
Old legacy path will be removed in Task 8 cleanup commit after visual
verification.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps the legacy hard-snap path in ACDREAM_INTERP_MANAGER=1 env-var
guard. When set, runs retail-faithful routing (acclient!CPhysicsObj::
MoveOrTeleport @ 0x00516330):
- distance > 96m → hard-snap (SetPositionSimple equivalent)
- distance ≤ 96m → Interp.Enqueue (queue for adjust_offset to walk to)
- teleport flag → hard-snap (default false until sequence plumbing)
- has_contact false → no-op (default true until parser plumbing)
Existing hard-snap behavior preserved when flag unset (default).
Old path will be removed in cleanup commit (Task 8) after visual
verification.
Helper: ExtractYawFromQuaternion (inverse of GameWindow.YawToAcQuaternion).
TODO followups (filed as plan known-limitations):
- IsStaleSequence (uint16 wrap-aware compare on 4 sequence counters)
- HasContact wire field (CreateObject.ServerPosition gap)
- Teleport-sequence comparison
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Composes the InterpolationManager (Task 1+2) into the per-remote
RemoteMotion container in GameWindow. Field exists but is not yet
consumed — Tasks 4 and 5 wire it into the routing + per-frame tick.
No behavior change. Build + 105 tests still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ports retail's CMotionInterp::get_max_speed (0x00527cb0). Returns
motion-table-derived max speed (m/s) for InterpretedState.ForwardCommand:
- RunForward: RunAnimSpeed (4.0) × (InqRunRate ?? MyRunRate)
- WalkForward: WalkAnimSpeed (3.12)
- WalkBackward: WalkAnimSpeed × 0.65 (BackwardsFactor from adjust_motion @ 0x00528010)
- otherwise: 0
Decomp note: Binary Ninja emits a spurious void return for x87 FPU-returning
functions; the actual float return is confirmed by both callers
(StickyManager::adjust_offset @ 0x00555430,
InterpolationManager::AdjustOffset @ 0x00555d52) which multiply the result
by 2.0 to produce a catch-up speed in m/s. The per-command switch is
consistent with get_state_velocity (0x00527d50) which uses the same constants.
Used by InterpolationManager.AdjustOffset in Task 5 as 2 × GetMaxSpeed().
Until Task 5 wires it, the method is unused — covered by 4 unit tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses code-quality review findings on commit f43f168:
C-1: Stall detection re-implemented to match retail (acclient lines
353071-353275). Tracks _progressQuantum (sum of step values per window)
+ _distanceAtWindowStart (set at window start). Primary check:
cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20m absolute).
Secondary check: cumulative_progress / _progressQuantum < 0.30.
Either failing increments fail counter; blip-to-tail at >3 consecutive
fails (already correct).
C-2: Renamed StallFailCountForBlip -> StallFailCountThreshold with
clearer XML doc explaining the > vs >= semantics (blip fires when fail
count EXCEEDS the threshold, i.e. on the 4th consecutive failed window).
I-1: _haveBaselineDistance sentinel prevents first-window false
positive that was triggering spurious fails on every new motion sequence
(old code defaulted _distanceAtWindowStart to 0, making cumulative
progress always negative on frame 5).
I-3: dt <= 0 || NaN guard at AdjustOffset entry prevents NaN
propagation into PhysicsBody.Position.
I-4: Internal field renames for clarity:
_failFrameCounter -> _framesSinceLastStallCheck
_failDistanceLastCheck -> merged into _distanceAtWindowStart
I-5: Added internal Count property + InternalsVisibleTo (via
AssemblyAttribute in .csproj) so Enqueue_DropsOldestWhenAtCap20
actually verifies cap enforcement. Added assertion that head is the
second-enqueued position after overflow.
3 new tests (AdjustOffset_FirstWindow_DoesNotFalseFail,
AdjustOffset_DtZeroOrNegative_ReturnsZero,
Enqueue_AtCap20_HeadIsSecondOriginal), 16 total. All green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure-data class + 13 unit tests.
Ports retail's CPhysicsObj::InterpolateTo (acclient @ 0x005104F0)
and InterpolationManager::adjust_offset (@ 0x00555D30) — FIFO position-
waypoint queue (cap 20) + per-frame catch-up math walking the body
toward the head node at 2 × motion-table-max-speed (clamped, with
7.5 m/s fallback). Reach @ 0.05m. Duplicate-prune @ 0.05m.
Stall detection: every 5 frames; if progress < 30% of expected,
increment fail counter; > 3 fails → blip-to-TAIL (resolved via
decomp dive of UseTime @ 0x00555F20: tail_ is the snap target,
not head_).
Constants verified from binary at named addresses (not guesses):
MAX_INTERPOLATED_VELOCITY_MOD=2.0, MAX_INTERPOLATED_VELOCITY=7.5,
MIN_DISTANCE_TO_REACH_POSITION=0.20, DESIRED_DISTANCE=0.05.
Composed into RemoteMotion in subsequent task; not yet used.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes a multi-bug knot in player motion outbound + remote inbound,
discovered via cdb live trace of retail (2026-05-01) and follow-up
visual verification.
Outbound (acdream → ACE):
- JumpAction velocity is BODY-LOCAL, not world (per retail
CPhysicsObj::get_local_physics_velocity at 0x00512140 + ACE
Player.HandleActionJump's set_local_velocity call). Was sending
world; observers saw jump rotated by player yaw.
- Capture get_jump_v_z BEFORE LeaveGround() — the latter resets
JumpExtent to 0, after which get_jump_v_z returned 0. Was sending
Z=0 in every JumpAction.
- Backward/strafe-left jumps lost their horizontal velocity because
LeaveGround → get_state_velocity returns zero for non-canonical
motion (faithful to retail's FUN_00528960; retail papers over via
adjust_motion translation, not yet ported). Compute the correct
body-local launch velocity from input directly and push it back
into the body so local prediction matches what we send.
- IsRunning HoldKey was gated on `input.Run && input.Forward`, so
strafe-run and backward-run incorrectly broadcast as walk to
observers — ACE then animated walk + dead-reckoned at walk speed
while server position moved at run speed (visible as observer
lag). Fixed: gate on any active directional axis.
- AutonomousPosition heartbeat 0.2s → 1.0s to match holtburger's
AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the ~1Hz observed in
retail trace.
- Heartbeat now fires while in-world regardless of motion state
(matches holtburger + retail's transient_state-based gate, not
motion-based). Pre-fix the at-rest heartbeat was suppressed.
Inbound (ACE → acdream, remote retail player):
- Remote backward walk arrives as cmd=WalkForward + speed=-1.91
(retail's adjust_motion'd form). Two bugs were stacking:
1. AnimationSequencer fast-path returned without updating when
sign(speedMod) flipped while motion stayed equal — kept playing
forward at old positive framerate. Fixed: bypass fast-path on
sign change so the full re-setup runs.
2. GameWindow clamped negative speedMod to 1.0 when stuffing
InterpretedState.ForwardSpeed, making get_state_velocity
produce forward velocity. Fixed: pass speedMod through verbatim
so the dead-reckoning body translates backward.
Issue #38 filed: 30Hz physics tick produces a chase-camera smoothness
regression at 60+ FPS render. Standard render-time interpolation is
the recommended fix (separate phase).
Findings + comparison vs retail/holtburger:
docs/research/2026-05-01-retail-motion-trace/findings.md
docs/research/2026-05-01-retail-motion-trace/fixes.md
TODO: port retail's adjust_motion (FUN_00528010) properly so
get_state_velocity works for all directions natively — would let us
drop the workaround in PlayerMovementController jump path and the
clamp in GameWindow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Filed as #37 after a ~3 hr investigation that ruled out animation source,
backface culling/winding, palette overlay, and head-GfxObj polygons.
Confirmed:
- Stub is from part 9 (upper torso/coat) post-AnimPartChange (gfx 0x0100120D)
- Part 9's both surfaces ARE matched by our 2 TextureChanges
- Server data complete; composition formula matches ACME + retail decomp
Untested hypothesis space (next session):
- Texture decode chain (compare our SurfaceDecoder vs ACME TextureHelpers)
- Polygon-to-surface index off-by-one on part 9
- Multi-layer texture composition AC may do
- UV mapping bug
Diagnostic env vars committed to source for next-session reuse:
- ACDREAM_HIDE_PART=N — hide specific humanoid part to localize bugs
- ACDREAM_NO_CULL=1 — disable backface culling
- ACDREAM_DUMP_CLOTHING=1 — dump APC + TC + per-part Surface chain coverage
Bug was originally reported as "head/neck protrudes forward"; the apparent
forward shift turned out to be an optical illusion from the missing
coat collar. Math + cdb-ground-truth + ACME comparison confirmed the
head placement is correct retail-faithful — see #37 for the long write-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three intertwined changes from a single investigation session driven by
attaching cdb to a live retail acclient.exe (v11.4186, Sept 2013 EoR
build) and tracing what retail actually DOES on the steep-roof wedge
scenario the user reported in acdream.
═══════════════════════════════════════════════════════════
1. L.5 — physics-tick MinQuantum gate (PlayerMovementController)
═══════════════════════════════════════════════════════════
Retail's CPhysicsObj::update_object subdivides per-frame dt into 1/30 s
sized integration steps and SKIPS entirely when accumulated dt is below
MinQuantum. Live trace evidence:
update_object = 40,960 calls
UpdatePhysicsInternal = 25,087 calls (61%)
i.e., 39% of update_object calls return early via the MinQuantum gate.
Retail's effective physics tick rate is 30Hz even at 60+ Hz render.
acdream's PlayerMovementController bypassed the existing PhysicsBody.
update_object and called UpdatePhysicsInternal(dt) directly each render
frame, which compressed bounce-energy / gravity-tangent accumulation
into half the time and amplified our steep-roof wedge dynamics.
Fix: add `_physicsAccum` accumulator. Integrate only when accumulated
dt ≥ MinQuantum (clamped to MaxQuantum to bound stale-frame jumps).
HugeQuantum drops accumulated time to discard truly stale frames
(debugger break, GC pause). Render still runs at full rate; only the
physics step is gated.
═══════════════════════════════════════════════════════════
2. Phase 3 reset retail-faithful kill_velocity (TransitionTypes)
═══════════════════════════════════════════════════════════
Retail's reset path (acclient_2013_pseudo_c.txt:273231-273239) gates
kill_velocity on `last_known_contact_plane_valid`:
if (last_known_valid == 0) {
set_collision_normal(step_up_normal); return COLLIDED;
}
kill_velocity(this);
last_known_valid = 0;
return COLLIDED;
Earlier in this session I deviated to "unconditional kill_velocity" as
a hypothesis-driven wedge fix. The live trace then showed the
deviation CAUSED a different wedge by zeroing V every frame, leaving
the body with no tangent momentum to escape (V = (0,0,0) for 169
consecutive frames while position pre/resolved frozen). The retail-
faithful gate is restored.
Note: the gate rarely fires in normal airborne play because our L.2.4
proximity guard clears last_known_valid soon after the body separates
from its remembered floor. Live retail trace also showed
kill_velocity = 0 hits over an entire play session — same behavior. So
acdream's kill_velocity is correct as ported now.
The supporting ObjectInfo.VelocityKilled flag + StopVelocity wiring +
PhysicsEngine.ResolveWithTransition consumer that actually zeros
body.Velocity when the flag is set — these were a no-op stub before
this session and are now correctly wired. Retail anchor:
OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0) at
acclient_2013_pseudo_c.txt:274467-274475.
═══════════════════════════════════════════════════════════
3. Retail debugger toolchain (#35)
═══════════════════════════════════════════════════════════
When the question is "what does retail actually DO at runtime?" — not
"what does retail's code SAY" — the decomp at docs/research/named-retail/
is invaluable but doesn't capture state interactions across frames.
This commit ships infrastructure to attach Windows' cdb.exe to a live
retail acclient.exe with full PDB symbols and capture state at any
breakpoint.
- tools/pdb-extract/check_exe_pdb.py — reads any PE's CodeView entry
and reports MATCH / MISMATCH against refs/acclient.pdb's GUID.
Always run before attaching cdb. The matching v11.4186 build's
GUID is 9e847e2f-777c-4bd9-886c-22256bb87f32.
- tools/pdb-extract/dump_pdb_info.py — dumps a PDB's expected
build timestamp + GUID + age. Used to figure out which acclient.exe
build pairs with our PDB.
CLAUDE.md gets a Step -1 in the development workflow ("ATTACH cdb
TO RETAIL when behavior is the question, not code") and a full
"Retail debugger toolchain" section with the workflow, sample .cdb
script structure, and watchouts (PDB names use snake_case for some
classes / PascalCase for CPhysicsObj; ; is cdb's command separator;
killing cdb kills the debuggee; high-hit-rate breakpoints lag the game).
memory/project_retail_debugger.md captures the workflow + key findings
so future sessions inherit the toolchain by reading project memory.
═══════════════════════════════════════════════════════════
4. BSPQuery Path 6 slide-tangent restored (b1af56e behavior)
═══════════════════════════════════════════════════════════
After this session's retail-strict experiments showed that retail-
faithful Path 6 (SetCollide + Phase 3 reset chain) produces a
"lands on roof in falling animation, can't slide off" half-state in
acdream — because our acdream port of step_up_slide / cliff_slide is
incomplete for grounded-on-steep movement — the L.4 slide-tangent
deviation from commit b1af56e is restored as the pragmatic ship state.
The deviation: when an airborne sphere hits a polygon whose normal Z
is below FloorZ (≈ 0.6642, slope > ~49°), project the move along the
steep face to remove the into-wall displacement, set CollisionNormal +
SlidingNormal, return Slid. Body never gets ContactPlane on the steep
poly, never gets the half-state, slides off the slope under gravity's
tangent contribution.
Retail-strict requires the deeper step_up_slide / cliff_slide audit
(filed under #32). Until that lands, slide-tangent is the right
deviation — produces user-acceptable "slide off the roof" behavior.
═══════════════════════════════════════════════════════════
Test status: 833/833 green.
Refs:
acclient_2013_pseudo_c.txt:283950 (CPhysicsObj::update_object)
acclient_2013_pseudo_c.txt:273231-273239 (Phase 3 reset path)
acclient_2013_pseudo_c.txt:274467-274475 (OBJECTINFO::kill_velocity)
acclient_2013_pseudo_c.txt:323783-323821 (BSPTREE::find_collisions Path 6)
Closes#35. Updates #32 with L.4/L.5 status.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase L.4 closes the "stuck in falling animation on a steep roof" bug
the user reported on 2026-04-30 ("I jump up, I land on it. It should not
even let me land, should just slide with a falling animation"). After
this commit the body no longer sticks to a steep roof when jumping
into it — it slides along the slope while keeping the falling animation.
Two pieces:
1. BSPQuery Path 6 steep-poly slide
When an airborne sphere hits a polygon whose world normal Z is below
FloorZ (≈ 0.6642, slope > ~49°), the previous flow was:
Path 6 SetCollide → Path 4 set_walkable → ContactPlane committed →
body "lands" on the steep poly with Contact bit + falling animation.
This left the player stuck mid-slope because OnWalkable was cleared
but Contact stayed set.
The new branch detects the steep normal in Path 6 BEFORE SetCollide
is called. Instead of entering the landing path, it removes the
into-wall component of the move (project onto the steep face), sets
CollisionNormal + SlidingNormal, and returns Slid. Same shape as
Path 5's step-up fallback and CylinderCollision. The resolver retries;
the sphere is now outside the poly; FindCollisions returns OK;
ValidateTransition commits the slid position. ContactPlane is never
set, so the body stays airborne with falling animation.
2. PlayerMovementController L.3a-bounce carve-out + Inelastic stop
Re-enables the velocity-reflection bounce when the contact normal is
upward-facing but steeper than walkable (0 < N.Z < FloorZ). The base
L.3a rule suppresses bounce on landing transitions to avoid micro-
bounce on flat terrain; that suppression also stuck the player to
too-steep roofs they shouldn't land on. This carve-out re-enables
the reflection specifically for the steep upward case.
Also lands related L.2c precipice / edge-slide work that was in flight:
- TransitionTypes EdgeSlideAfterStepDownFailed: walkable-poly-steep
cliff route + steep-ContactPlane cliff route ordering, so that
CliffSlide fires when the stored walkable polygon itself is too
steep (Path 4 had previously accepted it as a "landing" via the
permissive LandingZ threshold).
- CliffSlide reference-normal selection: prefer LastWalkable, fall back
to LastKnownContactPlane only when walkable, else use world-up. This
prevents the cross(steepN, steepN) = 0 degenerate case that left the
cliff slide as a no-op when both current and last-known were steep.
- Phase 2 / step-down branch / edge-slide branch / cliff-slide
diagnostic helpers gated on ACDREAM_DUMP_EDGE_SLIDE / ACDREAM_DUMP_STEEP_ROOF.
- Two new airborne-mover regression tests in BSPStepUpTests +
PhysicsEngineTests covering wall-slide and edge tangent motion.
DEVIATION FROM RETAIL — DOCUMENTED FOR FOLLOW-UP
The Path 6 steep slide is NOT what retail does. Retail's flow on the
same hit is:
Path 6 SetCollide (no steep check) → Path 4 find_walkable returns
nothing for steep → Phase 3 reset path: restore_check_pos +
kill_velocity → return COLLIDED → validate_transition reverts CheckPos
to CurPos and forces OK.
Net retail behavior: position reverts to pre-failed-move (typically
just below the roof in the common jump-up case), velocity zeroed,
gravity rebuilds Z next frame, body falls back down naturally with
the falling animation. The "freeze" framing I used earlier was wrong;
in the typical case retail just bounces the body off and lets gravity
take over.
Strict retail behavior would match the user's intent better in the
common case AND avoid the bounce-energy-accumulation we saw with the
slide-tangent approach (V grew to ~50 m/s in continuous-contact frames).
However, retail's behavior degenerates in the edge case of an overhead
landing onto a steep slope (body would freeze mid-air above the roof).
This commit ships the slide-tangent fix as an interim "much better"
state per user verification on 2026-04-30. Follow-up work to match
retail strictly: revert Path 6 steep-slide, audit Phase 3 reset to
ensure kill_velocity (matching OBJECTINFO::kill_velocity ->
CPhysicsObj::set_velocity({0,0,0}, 0)) actually fires, and re-test.
Refs:
- acclient_2013_pseudo_c.txt:323784-323821 (Path 6 SetCollide)
- acclient_2013_pseudo_c.txt:273191-273239 (Phase 3 reset path)
- acclient_2013_pseudo_c.txt:272563-272596 (validate_transition revert)
- acclient_2013_pseudo_c.txt:274467-274475 (kill_velocity)
- acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions bounce)
Tests: 833/833 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported: "still don't slide down steep roofs" after the previous
trigger-gate fix (52e257d). Traced through the EdgeSlide dispatcher:
the gate IS firing now, but ValidateTransition's L.2.3i FloorZ test
clears OnWalkable as soon as the player is on a steep surface. So
EdgeSlideAfterStepDownFailed enters Branch 1 (`!OnWalkable → restore
+ OK`) and stops the player BEFORE Branch 2's steep-ContactPlane
CliffSlide can fire.
Re-order: check the steep-ContactPlane condition FIRST, before the
Branch 1 OnWalkable gate. If the surface is too steep AND we have a
contact plane on it AND the EdgeSlide flag is set, run CliffSlide
regardless of OnWalkable state. The cross-product deflection plus
gravity produces continuous downhill drift, frame after frame.
Branch 1's "stop at edge" still fires for the original case it was
meant for: walked off into thin air with no contact plane at all.
That should still stop (or fall normally) rather than CliffSlide
against nothing.
Tests: 1491 still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surprise discovery: CliffSlide, PrecipiceSlide, and
EdgeSlideAfterStepDownFailed are ALREADY in the codebase (landed
yesterday in the L.2c Codex commits — 1ec40f2). The trigger gate was
the missing piece for "sliding down steep roofs / steep terrain."
The step-down branch in TransitionalInsert (where
EdgeSlideAfterStepDownFailed gets called) was gated on
`!ci.ContactPlaneValid` only. That covers "player walked off a ledge,
no ground beneath them anymore" — but NOT "player standing on a
surface that's too steep to walk on."
For the latter case, Phase 1 of the resolver sets ContactPlane to the
slope's plane (geometric touch is enough to set it; no walkability
gate at that stage). So `ci.ContactPlaneValid` is true, just steep.
Old gate skipped → step-down never ran → EdgeSlide never fired →
CliffSlide never deflected the player.
New gate fires when ContactPlane is invalid OR Normal.Z < FloorZ.
The latter case lets step-down attempt to find a walkable surface
below; it fails (the slope is steeper than FloorZ all the way down);
EdgeSlideAfterStepDownFailed runs; Branch 2 (steep ContactPlane) fires
CliffSlide; player gets deflected horizontally. Gravity continues to
pull Z down — the combination produces the visible "slide down the
slope" behavior.
Mirrors retail's `transitional_insert` OK-path which (per agent
reports of acclient_2013_pseudo_c.txt:273191) ALWAYS runs the
step-down chain after a successful tentative move, regardless of
ContactPlane validity. Our two-condition gate approximates that.
Tests: 1491 still pass.
Live verification: walking onto a 60° slope or jumping onto a steep
roof should now slide the player downhill rather than letting them
stand there indefinitely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tried bumping calc_friction's gate from `dot >= 0f` to `dot >= 0.25f`
per retail acclient_2013_pseudo_c.txt:276705. Build green but
PlayerMovementControllerTests immediately showed forward motion
dropping from ~3m to ~0.16m over a 1-second simulated walk —
friction now hammers active locomotion in our architecture.
Root cause is deeper than a single threshold. Retail line 276702 has
a state-flag check (`(this->state & ...) == 0`) gating the friction
block that the decompile renders as a corrupted string and we didn't
fully characterize. Best read: retail skips this friction block while
locomotion is actively driving velocity, applying it only to residual
motion after locomotion stops. acdream's controller sets velocity
once per frame from input, then UpdatePhysicsInternal substeps friction
through it — at 0.25 threshold the substep compounding eats most of
the velocity before integration completes.
Reverting to the previous behavior (0.0 threshold). Filing the proper
investigation as L.3c-followup: needs to read retail's `(this->state &
...)` flag at acclient_2013_pseudo_c.txt:276702, identify whether
it gates on an active-locomotion bit, and either honor that gate or
restructure acdream's per-frame locomotion → integration ordering so
friction fires only on residual velocity.
Tests: 1491 still pass after revert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to L.3a (a1c27b3) which ported the velocity-reflection bounce.
Previously the CreateObject parser did `pos += 4` for both Friction and
Elasticity floats — silently dropping the wire data so every entity got
the PhysicsBody constructor default (0.05 elasticity, 0.5 friction).
Server-set bouncier surfaces or stickier objects therefore felt
identical to inert walls on collision. Inelastic projectiles via
PhysicsState bit 0x20000 (already plumbed in Commit A) had no per-
object elasticity to override.
Now the parser captures the floats, surfaces them on Parsed +
EntitySpawn, leaving the values at default (null) when their
PhysicsDescriptionFlag bits aren't set. Subscribers (e.g., the
remote-entity dead-reckoning path, future spell-projectile rendering)
can apply them when they wire elasticity to PhysicsBody.Elasticity.
The local player's PhysicsBody is constructed at controller init,
not from a CreateObject — so this commit alone produces no
user-visible local-player change. Effect lands when remote/projectile
physics consume EntitySpawn.Elasticity.
Files:
- CreateObject.cs:284-294: declare friction + elasticity accumulators.
- CreateObject.cs:467-487: parse floats instead of skipping.
- CreateObject.cs:543-555: propagate to Parsed via both return paths.
- WorldSession.cs:67-71: extend EntitySpawn record.
- WorldSession.cs:665-668: pipe through to subscribers.
Tests: 1491 still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent research agents converged: retail's "bouncy walls"
feel comes from CPhysicsObj::handle_all_collisions (acclient_2013_pseudo_c.txt:
282699-282715, ACE PhysicsObj.cs:2692-2697) which applies the canonical
reflection v_new = v - (1 + e) * dot(v, n) * n to the body's velocity
after every transition resolves. Player elasticity = 0.05 (5% bounce);
INELASTIC_PS = 0x20000 zeros velocity entirely (used by spell projectiles).
acdream had the data plumbed (PhysicsBody.Elasticity = 0.05 was already
set, ci.CollisionNormal was being populated in 8+ code paths) but
ResolveWithTransition discarded the normal before returning. Hence
"sticky walls on jumps" — perpendicular velocity got removed by
SlideSphere's geometric resolution, but never reflected back, so
hitting a wall mid-jump zeroed forward motion entirely instead of
producing a small push-back.
Files:
- PhysicsBody.cs: add PhysicsStateFlags.Inelastic = 0x20000.
- ResolveResult.cs: surface CollisionNormalValid + CollisionNormal.
- PhysicsEngine.cs:599-624: copy ci.CollisionNormal into ResolveResult
before returning (both ok and partial paths).
- PlayerMovementController.cs:445-503: after position commit, apply
reflection per the retail formula. Inelastic → zero velocity;
else → reflect with v += n * -(dot(v,n) * (e + 1)).
apply_bounce rule (more conservative than retail by design):
- Sledding: retail's strict rule — bounce unless both grounded.
- Otherwise: bounce ONLY when both prev and now airborne. Suppress on
landing (prev air, now ground) to avoid micro-bouncing on floor —
the post-reflection upward Z defeats the controller's Velocity.Z<=0
landing-snap gate. Retail's elasticity 0.05 makes the artifact
visually imperceptible there; acdream's per-frame architecture
amplifies it.
Tests: 1491 → 1491 still pass (existing AirborneFrames + WalkOffLedge
tests confirmed the conservative apply_bounce rule keeps landings
clean).
Live verification needed: jump into a wall mid-air — should produce a
visible bounce-back rather than sticking. Walking along corridor with
side-clip should still slide. Landing should still settle without
micro-bounce.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port the first retail precipice-slide slice from named retail/ACE: terrain and BSP walkable hits now preserve polygon vertices, failed step-down edges back-probe to rediscover the walkable polygon, and edge-slide can run precipice/cliff slide instead of only hard-stopping.
Adds pseudocode anchors plus regression coverage for terrain polygon context and loaded-terrain boundary edge-slide.
Co-authored-by: Codex <codex@openai.com>
Pass explicit grounded/airborne contact bytes from MovementResult into MoveToState and AutonomousPosition, and add ACDREAM_DUMP_MOVE_TRUTH logging for outbound movement plus player UpdatePosition echoes.
Co-authored-by: OpenAI Codex <codex@openai.com>
Two parallel research agents converged on this bug. acdream's
ValidateTransition was setting OnWalkable based on `Normal.Z >= LandingZ`
(0.087, ~85° permissive) instead of `Normal.Z >= FloorZ` (0.664, ~49°
strict). Effect: a 60° roof slope (normal.Z = 0.5) was being marked
OnWalkable, letting the player walk freely up surfaces retail blocks.
Per retail PhysicsObj::is_valid_walkable
(acclient_2013_pseudo_c.txt:277180-277193) and ACE
PhysicsObj.cs:2861, the canonical "walkable" predicate is FloorZ.
LandingZ is the more permissive threshold used only in airborne→ground
transitions (Path 6 Collide handler) where we want to accept a brief
landing before the next frame's strict FloorZ check rejects the surface
and CliffSlide kicks in.
Three sites fixed:
1. Step-down branch's `zVal` initial value (was unconditional LandingZ;
now `oi.GetWalkableZ()` returns FloorZ when OnWalkable, LandingZ
otherwise — matches retail's transitional_insert step-down OK
branch at acclient_2013_pseudo_c.txt:273258-273265).
2. ValidateTransition's live-contact OnWalkable test (LandingZ → FloorZ).
3. ValidateTransition's LastKnown-fallback OnWalkable test (LandingZ →
FloorZ).
After this commit:
- Walking horizontally INTO a 60° slope: step-up's WalkableAllowance
is FloorZ (when OnWalkable), find_walkable rejects the slope's
polygon, step-up fails, StepUpSlide. Player blocked from climbing.
- Jumping ONTO a 60° roof: Path 6 still uses LandingZ (correct, we
want to land), so the player lands. Next frame: ValidateTransition
sees Normal.Z=0.5 < FloorZ → OnWalkable cleared. Player is Contact
but not OnWalkable. Currently this leaves them STUCK on the roof
(no CliffSlide yet to push them off). That's still better than
walking up the roof.
Full slide-off-roof + edge-slide-along-balcony behaviors require
porting CliffSlide + PrecipiceSlide + adding Walkable polygon
reference — that's Phase L.4 (~12-20h, sketched out by both research
agents). This commit unblocks the worst of the steep-walk-up behavior
while the bigger port is being designed.
Test count 825/825 still pass. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live-test bug: player getting "super stuck" near walls without touching
them. Diagnostic showed 0 step-up calls, so the issue wasn't in DoStepUp.
Root cause: my subagent's L.2.1 commit added a Placement validation
inside DoStepDown to prevent step-up-through-walls. That check is right
for DoStepUp's call (the original use case). But DoStepDown is ALSO
called from TransitionalInsert's contact-recovery branch when the per-
sub-step contact plane is briefly lost (e.g., right after a wall-slide
nudges the sphere slightly upward).
For that "maintain contact during normal movement" use, the Placement
check is over-strict. Wall-slide can leave the sphere with sub-EPSILON
overlap of the wall's BSP solid; SphereIntersectsSolid returns Collided
inside Placement; DoStepDown returns false; my L.2.3e then escalates
that to TransitionState.Collided in the outer loop; ValidateTransition
reverts the position to CurPos every frame. Result: player stuck near
the wall without ever touching it.
Fix: add a `bool runPlacement = true` parameter to DoStepDown.
- DoStepUp passes the default (Placement runs — protects step-up).
- TransitionalInsert's contact-recovery branch passes false (Placement
skipped — accepts whatever walkable surface is found within reach).
This preserves L.2.3e's edge-block (genuine edges return Collided
because no walkable is found, not because Placement rejected) while
unbreaking normal-walking-near-walls.
ACE Transition.cs:731-741 runs Placement unconditionally, but ACE's
pre-step-down state machine is cleaner — acdream's residual wall-slide
artifacts make Placement misfire here.
Test count 825/825 still pass. Build clean.
Live verification needed: walk near a wall, should no longer get stuck.
Walk off a tall (>1.5m) balcony, should still edge-block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Enhances the ACDREAM_DUMP_STEPUP=1 diagnostic so we can characterize
the steep-roof bug. The original log only showed the input collision
normal of the polygon that triggered step-up; it didn't show what
polygon the step-up actually LANDED on (which can differ — step-up
scans for any walkable polygon within StepUpHeight reach, so it might
ascend onto a flatter surface higher up than the polygon hit).
New log lines:
stepup: enter normal=(...) → WALKABLE/STEEP, OnWalkable=..., StepUpHeight=...
stepup: SUCCESS — landed on plane normal=(...) → WALKABLE/STEEP, new CheckPos=...
stepup: FAILED — sliding back along normal
When user climbs the offending steep roof, the SUCCESS line will tell
us whether the landing polygon is steeper than FloorZ=0.66 (then we
have a threshold bug) or whether step-up scanned past the steep slope
to land on a flatter polygon (then the StepUpHeight reach is too
permissive).
Also logs CurPos and final CheckPos so we can correlate to in-world
location.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-up fixes from live testing of the L.2.3 step-height pass.
L.2.3d — StepUpSlide actually applies the slide
Previously SpherePath.StepUpSlide only set ci.SlidingNormal as a flag and
returned Slid; the CURRENT step's CheckPos was never adjusted, so the
sphere stopped dead at the wall. ValidateTransition's "default to UnitZ"
branch then propagated UnitZ into SlidingNormal, overwriting the wall
normal entirely. Net effect: stop-at-wall, no horizontal slide.
ACE's StepUpSlide (SpherePath.cs:309-317) calls Sphere.SlideSphere which
computes the actual slide offset against the contact-plane / wall-normal
crease and applies it to CheckPos. acdream already had the same logic in
Transition.SlideSphere as a private helper. Exposed as internal
SlideSphereInternal; routed StepUpSlide through it.
L.2.3e — step-down failure returns Collided (always-on edge block)
When walking forward off a balcony / cliff, the step-down probe in
TransitionalInsert searches stepDownHeight below CheckPos for a
walkable surface. On failure the previous code returned OK, which
ValidateTransition accepted — the player walked off the edge anyway,
with `RestoreCheckPos` reverting only to the position right after the
outer step's offset (still post-edge).
Per ACE Transition.cs:268-320 (EdgeSlide), retail's always-on default
for OnWalkable + !EdgeSlide-flag movers is to reject the move. Returning
Collided here makes ValidateTransition revert CheckPos to CurPos
(pre-step), giving the retail-faithful "stop at edge" behavior — both
on terrain cliffs and on building/balcony edges.
L.2.3f — diagnostic instrumentation for steep-roof investigation
GameWindow logs the player's actual StepUpHeight + StepDownHeight at
world-entry (along with the raw Setup.* values for comparison) so we
can confirm whether the dat-derived value matches retail's spec
(~0.4m) or is overriding to something larger.
Transition.DoStepUp logs the polygon's collision-normal Z (gated on
ACDREAM_DUMP_STEPUP=1 to keep cold-path noise low) so we can tell
whether step-up is being triggered against truly-walkable polygons
(Z >= FloorZ ≈ 0.66) or whether something steeper is sneaking through.
Tests: 825/825 still pass. The L.2 conformance fixtures cover the slide
path; D1 + D2 regression tests still pass with the StepUpSlide port.
Live verification needed for:
- #2 Wall slide: running close to a wall should slide along it.
- #4 Edge block: running off a balcony should stop at the edge.
- #3 Steep roof: launch with ACDREAM_DUMP_STEPUP=1 and report the
"stepup: normal=..." log lines when climbing the offending roof.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "stuck in falling animation against walls" live-test bug (intermittent,
hard to recover from). Two compounding issues, fixed at both layers.
(1) DoStepUp cleared CollisionInfo.ContactPlaneValid unconditionally at
the start of step-up. On step-up FAILURE, RestoreCheckPos restored
the position but the contact plane stayed cleared. Added a save/
restore around the clear so a failed step-up returns the mover to
its pre-attempt grounded state.
(2) ValidateTransition propagated the current frame's invalid contact
state into LastKnownContactPlane via:
ci.LastKnownContactPlaneValid = ci.ContactPlaneValid
This destroyed the prior frame's ground memory whenever the current
contact was momentarily lost (StepUpSlide clears ContactPlane).
Changed to: only OVERWRITE LastKnown when current is valid.
(3) The same ValidateTransition then set
oi.State &= ~(Contact | OnWalkable)
when ContactPlaneValid was false, even if LastKnown was still
valid. Added an "else if (LastKnownContactPlaneValid)" branch that
sets Contact + OnWalkable from LastKnown so the animation system
sees the mover as grounded.
Combined effect: walking into a too-tall wall now consistently slides
along the wall without ever flickering to the falling animation. The
mover's grounded state survives transient ContactPlane invalidation
during the step-up retry cycle.
Retail's `transitional_insert` has different upstream invariants that
keep ContactPlane valid more often, so retail doesn't need the
acdream-specific LastKnown fallback path. ACE has the same pattern as
retail; acdream's per-frame Resolve architecture exposes the gap that
this fix closes.
Tests:
- New D1 regression test: grounded mover into too-tall wall — must
end frame with grounded state preserved.
- New D2 regression test: same scenario — execution time bounded
(<100ms) to catch any future recursion issues.
Files:
- TransitionTypes.cs DoStepUp: save+restore ContactPlane around step-up
- TransitionTypes.cs ValidateTransition: preserve LastKnown + grounded
state from last-known when current is invalid
- BSPStepUpTests.cs: D1, D2 regression tests
Test count 825 → 825 (D1+D2 added in L.2.3 patch series). Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Path 5 (Contact mover hits BSP polygon) calls DoStepUp → DoStepDown →
TransitionalInsert(5) → FindObjCollisions → which can hit the same wall
again → Path 5 fires AGAIN → recursive DoStepUp.
Bounded by the inner numAttempts=5 budget, but with significant per-step
churn — every recursion clears and re-establishes the contact plane,
finishing in an inconsistent state when the ranges decay. Also produced
gratuitous slowdown against tall walls.
Retail (acclient_2013_pseudo_c.txt:272954) gates step_sphere_up on
`if (sp.step_up == 0 && sp.step_down == 0)`. acdream's port was
missing this guard. Mid-recursion we now fall back to the wall-slide
response that already exists for the no-engine path.
Files:
- BSPQuery.cs Path 5 (foot sphere): added `&& !path.StepUp && !path.StepDown`
- BSPQuery.cs Path 5 (head sphere): same guard
Live-test bug: walking into building walls intermittently locked the
player in falling animation, hard to recover. After the guard, the
single-shot wall-slide produces clean blocking + horizontal slide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two values were producing weird live-test behavior:
- PlayerMovementController.StepUpHeight default = 5.0f (5 meters) and
GameWindow's fallback = 2.0f. With these, walking horizontally into
a steep slope let the step-up scan find walkable polygons up to 5m
away, which often included a small building's flat top. The player
visually "teleported" up onto the roof and then could walk on
surfaces they should have just slid off.
- stepDownHeight was hardcoded 0.04f (4 cm) in two ResolveWithTransition
call sites. A typical stair step is 15–25 cm tall, so when the player
walked off the top of a stair onto level ground, the step-down probe
didn't reach the next surface. For one frame the contact plane was
invalid → ValidateTransition cleared OnWalkable → animation flickered
to falling → next frame gravity dropped + terrain found. Visible 1-frame
flicker reported as "small falling animation when reaching stair top."
Retail's Setup.step_up_height and Setup.step_down_height for human
characters are both ~0.4 m. Sourcing them from the player's Setup
(already cached in PhysicsDataCache) with a 0.4 m fallback when
the field is missing.
Files:
- PlayerMovementController.cs:104 — StepUpHeight default 5.0 → 0.4
- PlayerMovementController.cs (new) — StepDownHeight property, default 0.4
- PlayerMovementController.cs:414 — pass StepDownHeight from controller
- GameWindow.cs:7019-7036 — read Setup.StepDownHeight + reduce fallbacks
- GameWindow.cs:5759 — remote dead-reckoning: 2.0/0.04 → 0.4/0.4
No test changes; existing 12 BSPStepUp tests still cover the value flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port CTransition::step_up (Path 5) and SPHEREPATH::set_collide (Path 6)
from the retail decomp, turning wall-slides into proper step-up climbs
and airborne-to-roof landings.
Path 5 (grounded mover hits polygon):
- StepSphereUp calls DoStepUp which runs DoStepDown with StepUp=true
- DoStepDown now includes the retail Placement validation step
(ACE Transition.cs:731-741) — sphere must not be inside solid geometry
after finding a contact plane; this correctly blocks the tall-wall case
- FindObjCollisions now allocates a local ShadowEntry list per call to
prevent "collection modified" exceptions when DoStepUp recurses back
through TransitionalInsert → FindObjCollisions
- BSPQuery.FindCollisions passes engine through to StepSphereUp
Path 6 (airborne mover hits polygon):
- SpherePath.SetCollide: saves backup pos, records StepUpNormal, sets
WalkInterp=1 — then returns Adjusted so TransitionalInsert retries
- SpherePath.StepUpSlide: clears ContactPlane, sets SlidingNormal for
the tall-wall fallback
- TransitionalInsert Collide branch: re-tests as Placement when
ContactPlaneValid; on failure restores backup and returns Collided
Test fixes (BSPStepUpTests.cs + BSPStepUpFixtures.cs):
- Tests use foot-position convention (CurPos = foot, sphere center =
CurPos + (0,0,r)); from/to corrected from sphere-center to foot coords
- MakeTestEngine terrainZ param: 0f for grounded tests (keeps Contact
state between sub-steps), -50f for airborne/roof tests
- to.X adjusted so sub-steps land sphere inside (not exactly touching)
the wall, avoiding the EPSILON-shrink false-negative edge case
- All 12 BSPStepUp tests now GREEN; full suite 823/823
Retail refs:
CTransition::step_up — acclient_2013_pseudo_c.txt:273099 / ACE:746
CTransition::step_down — acclient_2013_pseudo_c.txt:273069 / ACE:710
SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594 / ACE:279
CTransition::transitional_insert Collide — pseudo_c:273193 / ACE:891
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds two files under tests/:
BSPStepUpFixtures.cs — synthetic PhysicsBSPNode trees for four canonical
collision shapes: low step (25 cm), too-tall wall (5 m), flat roof (3 m),
and steep slope (60deg). Pre-builds ResolvedPolygon dicts with correct
polygon_hits_sphere_precise winding (CCW relative to outward normal).
BSPStepUpTests.cs — 11 conformance tests:
A1-A6: baselines that pass before and after implementation (no-hit, geometry
fixture sanity checks).
B1-B3: Phase L.2.1 targets, currently RED (Path 5 wall-slides).
C1-C3: Phase L.2.2 targets, currently RED (Path 6 wall-slides).
Retail refs in test docstrings:
BSPTREE::find_collisions Path 5 acclient_2013_pseudo_c.txt:323849 /
ACE BSPTree.cs:192-196.
CTransition::step_up acclient_2013_pseudo_c.txt:273099-273133 /
ACE Transition.cs:746-777.
SPHEREPATH::set_collide acclient_2013_pseudo_c.txt:321594-321607 /
ACE SpherePath.cs:279-286.
CTransition::transitional_insert Collide branch
acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930.
Also adds PhysicsDataCache.RegisterGfxObjForTest() for test-only GfxObjPhysics
injection without real DAT content.
Test delta: 811 -> 823 (+12). 6 passing (A1-A6 + B2), 5 intentionally failing.
Pre-flight: object-translation plane D is in object-local space. Bug is dormant
for outdoor movement where terrain sets the world-space ContactPlane. Tagged TODO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The retail-faithful exemption block at the top of
CPhysicsObj::FindObjCollisions
(acclient_2013_pseudo_c.txt:276782-276839,276971), ported line-for-
line as a small static helper.
Behaviour now matches retail:
- Two non-PK players walk through each other.
- Two PK players collide.
- Two PKLite players collide.
- Mismatched PK status (PK vs non-PK, PK vs PKLite) — exempt.
- Impenetrable target ("Free" PK status) — always collides.
- Player vs creature/NPC — always collides (this is what closes the
user-facing complaint that walking into a Holtburg vendor was
walking through them).
- Mover with IGNORE_CREATURES — walks through creature targets.
- Viewer (camera ray) — walks through creatures.
- Target with ETHEREAL+IGNORE_COLLISIONS — universally exempt.
CollisionExemption.ShouldSkip(targetState, targetFlags, moverState)
- new file src/AcDream.Core/Physics/CollisionExemption.cs.
- 13-test matrix covering every documented case
(CollisionExemptionTests.cs).
- Static + pure → cheap to call from the hot path.
Wiring:
- TransitionTypes.FindObjCollisions: after broadphase distance
reject, call ShouldSkip on the obj and ObjectInfo.State; on true,
`continue`. Static landblock entries (State=0, Flags=None) fall
through cheaply — no behavior change for static collision.
- PhysicsEngine.ResolveWithTransition: new optional moverFlags
parameter (default None for back-compat). PlayerMovementController
passes ObjectInfoState.IsPlayer; remote dead-reckoning leaves it
None (matches non-player movers, no PvP exemption applies).
- PK/PKLite/Impenetrable bits for the LOCAL player are not yet
sourced from PlayerDescription's PlayerKillerStatus property —
that's a follow-up. Default "non-PK player" matches ACE's
character-creation default and the user's +Acdream test
character.
Cross-checked against ACE PhysicsObj.cs:381-405 (line-for-line C# port
of the same retail block). Only intentional divergence: ACE adds
state.HasFlag(IsImpenetrable) (mover-impenetrable) to the collide list;
retail's pseudo-C only checks the target — acdream follows retail.
dotnet build green, dotnet test 1467 passing (+13 new). Live test:
+Acdream walking into Holtburg vendors now stops at their cylinder;
walking through small plants still passes (Commit B's phantom skip).
Closes the live-entity collision arc: A (plumbing) + B (registration)
+ C (exemption).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NPCs / monsters / other players now register into ShadowObjectRegistry
as collision targets. The local player walks into them and stops at
the body cylinder, instead of passing through.
GameWindow.OnLiveEntitySpawnedLocked: after the WorldEntity is built
and stored in `_entitiesByServerGuid`, call the new
RegisterLiveEntityCollision helper for any non-self entity. The helper
honors retail's geometry-priority order (acclient_2013_pseudo_c.txt:
276858-276987) — CylSpheres > Setup.Radius > Sphere fallback — and
applies the retail phantom-Setup skip (no CylSpheres / no Spheres /
zero Radius → walk-through, matching FUN_FindObjCollisions's OK_TS
fallthrough at :276917).
GameWindow.OnLivePositionUpdated: after the entity's render pos/rot
are set to server truth, push the same coordinates into the registry
via ShadowObjectRegistry.UpdatePosition (the cheap
preserve-shape-and-flags path Commit A added). Mirrors retail's
SetPosition → change_cell → AddShadowObject chain (
acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
The local player's own server guid is filtered out at both
registration and update — its PhysicsBody is the simulator (the
source of truth for our collisions), not a collision target.
The decoded EntityCollisionFlags + raw PhysicsState bits are stored
on each ShadowEntry but NOT YET CONSULTED by the collision resolver
— Commit C is where the PvP exemption block lands. Practical effect
of THIS commit: every visible body, including non-PK other players,
blocks the local player. Two non-PK players currently can't pass
through each other; that's the rule Commit C reverts to retail.
No new unit tests in this commit (Commit A's ShadowObjectRegistry +
EntityCollisionFlags suite covers the new field plumbing).
Verification is live: at Holtburg the +Acdream test character should
stop on contact with NPCs / vendors. Phantom decorations (small
plants, grass) continue to pass through (L-fix3 phantom skip
extends naturally to the live path via the same Setup-shape gate).
dotnet build green, dotnet test 1454 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plumbing-only foundation for the upcoming live-entity (NPC / monster
/ player) collision port. No behavior change — the new fields default
to zero/None so the 5 existing static-entity Register call sites in
GameWindow.cs are untouched.
Wire layer:
- CreateObject parser now surfaces PhysicsState (acclient.h:2815 —
ETHEREAL_PS=0x4, IGNORE_COLLISIONS_PS=0x10, HAS_PHYSICS_BSP_PS=0x10000,
...) which the parser previously dropped at line ~337 with a bare
`pos += 4`.
- CreateObject parser now surfaces ObjectDescriptionFlags (the retail
PWD._bitfield trailer per acclient.h:6431-6463), where
acclient_2013_pseudo_c.txt:406898-406918 ACCWeenieObject::IsPK /
IsPKLite / IsImpenetrable read bits 5 / 25 / 21 directly. Previously
read-and-discarded.
- WorldSession.EntitySpawn carries both new fields through to subscribers.
Physics layer:
- New `EntityCollisionFlags` enum (IsPlayer / IsCreature / IsPK /
IsPKLite / IsImpenetrable) + `FromPwdBitfield` helper. Bit
positions verified against retail's SetPlayerKillerStatus (
acclient_2013_pseudo_c.txt:441868-441890) which maps
PKStatusEnum→bitfield exactly: PK=0x4→bit5, PKLite=0x40→bit25,
Free=0x20→bit21.
- `ShadowEntry` extended with `State` (raw PhysicsState bits) +
`Flags` (decoded EntityCollisionFlags). Backward-compatible — all
five existing landblock-entity Register call sites omit them.
- `ShadowObjectRegistry.UpdatePosition(entityId, pos, rot, ...)` —
fast-path for the 5–10 Hz UpdatePosition (0xF748) stream the server
emits per visible entity. Reuses the entry's existing shape +
state + flags. Mirrors retail's CPhysicsObj::SetPosition
(acclient_2013_pseudo_c.txt:284276) which keeps the same shape and
re-registers cell membership.
- `ObjectInfoState` adds `IsPK = 0x800` and `IsPKLite = 0x1000`
matching retail's OBJECTINFO::state bits (acclient.h:6190-6194).
Used by Commit C's PvP exemption gate.
Tests:
- `EntityCollisionFlagsTests` — 7 tests covering empty / each bit
alone / PK+player combo / unrelated-bit ignore.
- `ShadowObjectRegistryTests` — 5 new tests: UpdatePosition moves
entry to new cell, preserves State/Flags, unregistered no-op,
Register stores State/Flags, defaults are zero/None.
- `CreateObjectTests` — 3 new tests verifying PhysicsState + PWD
bitfield (with PK / PKLite bit cases) parse and surface.
1454 → 1454 + 15 = covered by suite. dotnet build + dotnet test
green.
Foundation for Commit B (live-entity registration) and Commit C
(PvP exemption block in FindObjCollisions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User asked how AC differentiates collidable vs phantom scenery.
The retail signal is at the Setup level: a Setup with NO
CylSpheres, NO Spheres, AND zero overall Radius is decorative
-- the player walks through it. retail header
docs/research/named-retail/acclient.h enum PhysicsState
includes ETHEREAL_PS = 0x4 / IGNORE_COLLISIONS_PS = 0x10 for
runtime-toggled flags, but the static signal for scenery is
the empty Setup collision arrays.
Pre-fix the mesh-bounds-fallback at
GameWindow.cs:4297-4429 ran on every outdoor scenery entity
regardless of Setup intent, then clamped the resulting
cylinder radius to >= 0.3 m. So small plants/grass got a
0.3 m collision cylinder and blocked the player even though
the Setup explicitly said no collision.
Fix: before the mesh-bounds fallback, check the cached Setup.
If it's a Setup-typed object (0x020xxxxx) AND CylSpheres /
Spheres / Radius are all empty/zero, mark it phantom and
skip the collision registration entirely. Non-phantom
scenery (trees with real CylSpheres or canopy-only BSPs)
still gets the mesh-bounds fallback so the player walks
under canopies but bumps into trunks. Raw GfxObjs
(0x010xxxxx, no Setup metadata) keep the old fallback
behaviour because they don't expose phantom intent.
Tests stay 1439 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: trees that exist in retail are missing in ACdream.
SceneryGenerator had an extra heuristic filter at lines 169-180
that rejected scenery whose cell-origin vertex was a road vertex,
on top of the proper retail post-displacement road check
(FUN_00530d30 port via IsOnRoad). The comment admitted it
wasn't in the retail decomp -- it was added to widen road
margins visually. Side effect: any cell whose SW corner
happened to touch a road vertex had ALL of its scenery
dropped, even when the displaced position was well clear of
the road ribbon.
Removing the extra guard. The retail FUN_00530d30 ribbon test
already handles road exclusion correctly; the heuristic was
strictly subtractive and silently dropped trees the retail
client renders.
Tests stay 1439 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: other characters disappear when the camera rotates,
even though they're standing within visible distance.
Root cause: InstancedMeshRenderer's landblock-level frustum cull
(InstancedMeshRenderer.cs:352-355) skipped the entire landblock's
entity list when the landblock AABB was outside the frustum.
Static scenery culling that way is fine, but ANIMATED entities
(remote players, NPCs, monsters) got culled with the landblock --
they vanished as soon as the camera turned away from their block.
Fix: pass an animatedEntityIds set to Draw. Inside CollectGroups
the landblock-cull decision is now per-landblock boolean (not a
continue), and the per-entity loop bypasses the cull when the
entity id is in animatedEntityIds. Static entities still respect
the landblock cull. GameWindow rebuilds the set per frame from
_animatedEntities (typically <100 entities, cheap).
Fast path preserved: when animatedEntityIds is null/empty AND
the landblock is culled, skip the entity list entirely -- same
O(visible-landblocks) cost as before.
Tests stay 1439 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 commits porting retail's MoveToManager-equivalent client-side
behavior for server-controlled creature locomotion and combat
engagement. Shipped as MVP after live visual verification across
multiple iteration rounds with the user.
Highlights:
- 186a584 — initial Phase L.1c port: extracts Origin / target guid /
MovementParameters block from MoveTo packets (movementType 6/7),
adds RemoteMoveToDriver per-tick body-orientation steering with
±20° aux-turn-equivalent snap tolerance.
- d247aef — corrected arrival predicate semantics + 1.5 s
stale-destination timeout for entities leaving the streaming view.
- f794832 — root-caused "creature won't stop to attack" via two
research subagents converging on retail
CMotionInterp::move_to_interpreted_state's unconditional
forward_command bulk-copy. Lifted ServerMoveToActive flag clearing
+ InterpretedState bulk-copy out of substate-only branch so
Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear
stale MoveTo state and zero forward velocity.
- ff6d3d0 — RemoteMoveToDriver.ClampApproachVelocity caps horizontal
velocity at the final-approach tick so body lands EXACTLY at
DistanceToObject instead of overshooting through the player.
- 37de771 — bulk-copy ForwardCommand for MoveTo packets too (closed
the regression where MoveTo creatures stayed at default
ForwardCommand=Ready in InterpretedState and only translated via
UpdatePosition snaps).
- 34d7f4d + e71ed73 — AnimationSequencer.HasCycle query +
fallback chain (requested → WalkForward → Ready → no-op) at BOTH
the OnLiveMotionUpdated path AND the spawn handler. Prevents
ClearCyclicTail from wiping the body's cyclic tail when ACE
CreateObject carries CurrentMotionState.ForwardCommand pointing
to an Action-class motion (e.g. AttackHigh1 from a mid-swing
creature) which has no cyclic-table entry — was the "torso on
the ground" symptom for monsters seen in combat by a fresh
observer.
Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt
(MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80,
CMotionInterp::move_to_interpreted_state 0x00528xxx,
MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/
ACE.Server/Physics/Animation/MoveToManager.cs (port aid),
references/holtburger/ (cross-check on snapshot-only client
behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md
(the Phase L.1c pseudocode doc).
Tests: 1404 → 1422 (parser type-7 path retention, type-6 target
guid retention, driver arrival semantics, retail-faithful
chase/flee branches, approach-velocity clamp scenarios,
HasCycle present/missing, AttackHigh1 wire layout).
Pending follow-ups (filed for future): target-guid live resolution
for type 6 packets (residual chase lag), StickToObject sticky-target
guid trailing field, full MoveToManager state machine port
(CheckProgressMade stall detector, Sticky/StickTo, use_final_heading,
pending_actions queue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reports same "torso on the ground" symptom after 34d7f4d. Likely
cause: my fallback only covered the OnLiveMotionUpdated path, not the
spawn handler at the CreateObject boundary. If the spawn-time
SetCycle requests a (style, motion) pair the MotionTable lacks,
ClearCyclicTail wipes the cyclic tail at line 396 of
AnimationSequencer.cs and every body part snaps to its setup-default
offset until the first OnLiveMotionUpdated UM applies the path's
fallback there.
Apply the same fallback chain (requested → WalkForward → Ready →
no-op-don't-clear) at the spawn handler. Also add a one-line
diagnostic dump (under ACDREAM_DUMP_MOTION=1) on both code paths so
the next launch confirms whether the fallback is actually firing and
what (mtable, style, motion) tuples are missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression on commit 37de771: monsters in combat with
another client appear as "just a torso on the ground" until they
move. User correctly identified this as a regression I introduced.
Cause traced to the SEQUENCER side, not the InterpretedState side.
AnimationSequencer.SetCycle (AnimationSequencer.cs:392-396)
unconditionally calls ClearCyclicTail() BEFORE looking up the
requested cycle in the MotionTable. If the cycle is missing
(_mtable.Cycles.TryGetValue returns false), the body is left without
ANY cyclic tail at all — and every part snaps to its setup-default
offset on the next Advance(). Most creatures' setup-defaults put
all limbs at the torso origin, so the visual collapses to "just a
torso on the ground" until a different (working) cycle arrives.
This is specifically a regression from commit 186a584 (Phase L.1c
port). Pre-fix, MoveTo packets fell through to fullMotion=Ready
(every MotionTable contains a Ready cycle). Post-fix, MoveTo packets
seed fullMotion=RunForward via PlanMoveToStart. Some combat-stance
creatures (e.g. monsters in HandCombat 0x003C) have no
(combat, RunForward) cycle in their MotionTable — they're meant to
walk in combat, with retail's apply_run_to_command upgrading
WalkForward → RunForward at the velocity layer rather than the
animation-cycle layer.
Fix: add `AnimationSequencer.HasCycle(style, motion)` query and gate
the SetCycle call site in GameWindow.OnLiveMotionUpdated behind it.
Fall back chain: requested motion → WalkForward → Ready →
no-op-don't-clear. The InterpretedState.ForwardCommand bulk-copy
(commit 37de771) is unchanged — body still gets RunForward velocity
even when the visible animation falls back to WalkForward or Ready.
Tests: 1420 → 1422.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression on commit ff6d3d0: at login, monsters appear
as "just a torso on the ground" until they start moving.
Cause: f794832 lifted the InterpretedState bulk-copy to apply to BOTH
overlay and substate packets, but gated it on
`!IsServerControlledMoveTo`. The original substate-only
DoInterpretedMotion call I removed had previously updated
InterpretedState for MoveTo packets too (fullMotion=RunForward seed
from PlanMoveToStart routed through ApplyMotionToInterpretedState's
RunForward case). My replacement only fired for non-MoveTo packets,
silently regressing MoveTo creatures to a default
ForwardCommand=Ready InterpretedState.
Consequence: chasing creatures had ForwardCommand=Ready in their
InterpretedState even though the cycle on the sequencer was
RunForward. apply_current_movement (gate: RunForward||WalkForward)
returned zero velocity — body never advanced via the steering branch's
velocity integration. The body ONLY translated when an UpdatePosition
hard-snap arrived (every ~200ms server tick), producing the
"torso on the ground at spawn" pose before the first UP snap landed
and "running on the spot" between snaps.
Fix: drop the IsServerControlledMoveTo gate. Bulk-copy
InterpretedState.ForwardCommand=fullMotion and ForwardSpeed=speedMod
UNCONDITIONALLY for any packet that reaches OnLiveMotionUpdated.
Matches retail's copy_movement_from
(acclient_2013_pseudo_c.txt:293301-293311) which doesn't filter by
movement type — for MoveTo, RunForward/speed*runRate; for substate,
the wire's command/speed; for overlay, Attack/animSpeed (and
get_state_velocity gates correctly to zero, the desired stop).
Tests still 1420 green — the existing parser/driver tests cover the
data; this is a code-path completeness fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed residual after f794832: creature stops to attack but
still runs slightly through the player before stopping.
Cause: at 4 m/s body velocity (RunAnimSpeed × ~1.0 speedMod) and a
60 fps tick (~16 ms), the body advances ~6.4 cm per tick. When dist
falls just below the 0.6 m DistanceToObject arrival threshold, the
arrival predicate fires and zeroes velocity — but the body has
already advanced one full tick INTO the threshold zone. That last
tick is the "running through" the user sees, especially when
combined with a player visual radius of ~0.5 m.
Fix: cap horizontal velocity in the steering branch so the body lands
EXACTLY at the arrival threshold instead of overshooting it. Pure
function in RemoteMoveToDriver (ClampApproachVelocity) so it's
testable; called from GameWindow.cs after apply_current_movement
sets RunForward velocity from the active cycle.
The clamp is a strict scale-down of the X/Y components; Z is left
to gravity / terrain handling. No-op for the flee branch — fleeing
has no overshoot risk by definition.
Tests: 1416 → 1420. Four new clamp scenarios: exact-landing (FP
tolerance), would-overshoot scale-down, already-at-threshold zeroing,
flee no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regression on commit d247aef: creature reaches melee
range and "just runs" instead of stopping to attack. Two independent
research subagents converged on the same root cause.
When ACE broadcasts a melee swing, it sends an mt=0 UpdateMotion with
ForwardCommand=AttackHigh1 (Action class, 0x10000062), motion_flags
=StickToObject, and a trailing 4-byte sticky-target guid — there is
NO preceding cmd=Ready. The swing UM IS the stop signal.
Retail's CMotionInterp::move_to_interpreted_state
(acclient_2013_pseudo_c.txt:305936-305992) bulk-copies forward_command
from the wire into InterpretedState UNCONDITIONALLY, regardless of
motion class. With forward_command=AttackHigh1, get_state_velocity
(:305172-305180) returns velocity.Y=0 because its gate is
RunForward||WalkForward — body stops moving forward. The animation
overlay (the swing) is appended on top of whatever cyclic tail is
active.
Acdream's overlay branch in GameWindow.OnLiveMotionUpdated routed
Action-class commands through PlayAction (animation overlay only) and
SKIPPED:
- ServerMoveToActive flag update — stale RunForward MoveTo state
persisted, the per-tick driver kept steering toward the prior
Origin and calling apply_current_movement.
- InterpretedState.ForwardCommand bulk-copy — even if the flag had
been cleared, the body's InterpretedState.ForwardCommand stayed
at RunForward from the prior MoveTo cycle, so
apply_current_movement kept producing forward velocity.
- MoveToPath capture — staleness-timeout band-aid masked this.
Fix: lift the _remoteDeadReckon state-update block out of the
substate-only `else` branch so it runs for both overlay and substate
paths. For non-MoveTo packets, write fullMotion + speedMod directly to
InterpretedState.ForwardCommand/ForwardSpeed (bypassing
ApplyMotionToInterpretedState, which is a heuristic helper that
silently no-ops for Action class — see MotionInterpreter.cs:941-970).
This matches retail's copy_movement_from
(acclient_2013_pseudo_c.txt:293301-293311) bulk-copy semantics.
Also corrected RemoteMoveToDriver arrival predicate to retail-faithful:
chase = dist <= DistanceToObject; flee = dist >= MinDistance. The
prior max(MinDistance, DistanceToObject) defensive port happened to
compute the right value for ACE's wire defaults but had wrong
semantics (would have failed for any retail config with MinDistance >
DistanceToObject).
Tests: 1414 → 1416. New parser test for the AttackHigh1 wire layout;
new driver tests for retail-faithful chase/flee arrival.
Defers: target-guid live resolution for type 6 packets (chase-lag
mitigation, symptom #3), StickToObject sticky-target guid trailing
field, full MoveToManager port (CheckProgressMade, pending_actions
queue, Sticky/StickTo, use_final_heading).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-observed regressions on commit 186a584:
1. "Monster keeps running in different directions when it should be
attacking" — chase oscillates around the player at melee range
instead of stopping. Root cause: arrival check used MinDistance
only (retail's algorithm), but ACE puts the melee threshold in
DistanceToObject (default 0.6) and leaves MinDistance at 0. So
our check was never satisfied; body kept re-targeting around the
player as each MoveTo refresh moved the destination.
Fix: arrival = dist <= max(MinDistance, DistanceToObject) + epsilon.
Honors retail when retail sets MinDistance > 0; falls through to
ACE's DistanceToObject when MinDistance is 0. Confirmed by
independent research (named retail decomp, ACE wire writers,
holtburger client) that DistanceToObject is the documented chase
threshold in ACE; retail's MinDistance is only meaningful when
server config overrides the default 0.
2. "Monster disappears, then runs in place" — entity left our
streaming view, server stopped emitting MoveTo, last destination
stayed cached. When entity re-entered view, body still steered
toward the stale point, eventually arrived (V=0), animation kept
playing → "running on the spot."
Fix: 1.5 s stale-destination timeout. ACE re-emits MoveTo at
~1 Hz during active chase; if no fresh packet for 1.5 s, the
entity has either left view, transitioned off MoveTo without us
seeing the cancel UM, or had its move cancelled server-side.
Clear destination + zero velocity so the next interpreted-motion
UM (or fresh MoveTo) drives the body cleanly.
Also confirmed (via dispatched research subagent against ACE writer
side, named retail MovementManager::PerformMovement, and holtburger):
the wire's "Origin" field IS the destination, not the start position.
My driver's interpretation was correct; the symptoms were arrival
threshold + staleness, not a misread of the wire.
Tests: 1412 → 1414 (ACE-melee arrival, retail-MinDistance arrival).
Origin-stale lag during active chase remains — server's Origin is
the target's position at packet-emit time, ~1 s behind the player.
For type 6 MoveToObject, the retail-faithful fix is target-guid
live resolution per HandleUpdateTarget @ 0x0052a7d0; deferred per
the pseudocode doc's "out of scope" list. For type 7 there's no
fix without target-velocity prediction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>