Replaces the hybrid that double-counted forward translation:
predicted body.Velocity (set per-tick by apply_current_movement) +
the seqVel-derived offset both pushed the remote body forward at
~11.7 m/s × dt for run, summing to ~23.4 m/s × dt — the user's
"way too fast" + 1-Hz blip.
Per the named-retail decomp investigation 2026-05-03 (research agent
report dispatched against acclient_2013_pseudo_c.txt for
CSequence::update + UpdatePositionInternal + UpdateObjectInternal +
adjust_offset, line citations in the env-var path comments):
CPhysicsObj::UpdateObjectInternal (0x005156b0)
→ UpdatePositionInternal (0x00512c30)
→ CPartArray::Update (writes anim root motion into the offset frame)
→ PositionManager::adjust_offset (REPLACES the offset with catch-up
when the body is far from the queue head; otherwise leaves the
anim root motion alone — Frame::operator=(arg2, &__return)
semantics, NOT additive)
→ Frame::combine (out = m_position + offset)
→ UpdatePhysicsInternal (out += body.Velocity × dt + 0.5·accel·dt²)
For a remote in steady-state RunForward where the server hasn't pushed
an explicit velocity, m_velocityVector ≈ 0 and ALL per-tick translation
comes from the animation root motion (CSequence::update_internal +
Frame::combine of crossed pos_frames keyframes). Our port doesn't
extract per-keyframe pos_frames from the .anm assets; instead
AnimationSequencer.CurrentVelocity is the synthesized equivalent
(RunAnimSpeed × ForwardSpeed averaged), passed through
PositionManager.ComputeOffset.
Concrete changes in the env-var (ACDREAM_INTERP_MANAGER=1) path:
* Pass seqVel = ae.Sequencer.CurrentVelocity to ComputeOffset (was
Vector3.Zero — that disabled the animation-root-motion source and
left only the queue catch-up to drive translation, which lagged
server pace).
* Clear rm.Body.Velocity to Vector3.Zero for grounded remotes each
tick. Mirrors retail's m_velocityVector ≈ 0 for remotes; prevents
UpdatePhysicsInternal from adding a second 11.7 m/s × dt on top of
the seqVel-driven translation.
* Stop calling apply_current_movement per tick. Retail only calls it
on motion-state changes (per cdb traces from the L.5 investigation),
not per physics tick. body.Velocity-based translation is now the
AIRBORNE-only path (gravity integration during jumps).
Also reverts an unacceptable "scaling hack" (per-tick body.Velocity
scaled by observed serverSpeed/predictedSpeed) the user explicitly
rejected as patching over an unsolved structural problem.
GetMaxSpeed reverted to RunAnimSpeed × rate (matches ACE
MotionInterp.cs:670-678; the earlier "return bare rate" change came
from a misread of an x87-decompiled get_max_speed where Binary Ninja
showed the return type as void).
AnimationSequencer.SetCycle now ALWAYS overwrites CurrentVelocity for
known locomotion cycles (Walk/WalkBackward/Run/SideStepRight/
SideStepLeft) instead of gating on `CurrentVelocity.LengthSquared() <
1e-9f`. The gate was correct for non-locomotion entities with
dat-baked HasVelocity, but for Humanoid where the dat is silent and
the only thing that could set CurrentVelocity before synthesis was a
transition link's HasVelocity flag, the gate would silently leave the
body advancing at the link's velocity instead of the cycle's intended
steady-state.
Adds wire-arrival diagnostics gated on ACDREAM_REMOTE_VEL_DIAG=1
(SETCYCLE, FWD_WIRE) used to trace the bug to ground truth.
User-confirmed improvements vs prior state:
- Steady-state run no longer "way too fast"
- Run-in-circles smoother (rectangle effect gone)
- Jump landing in correct location
- Turn-left visibly turns left
Outstanding (not addressed by this commit, deferred for next
investigation): walk↔run direct transitions don't visibly switch the
animation cycle until the next motion event fires. Both legacy and
new paths exhibit the same behavior, so the bug lives in the
SetCycle queue manipulation pipeline shared by both — not in the
per-tick translation path that this commit revises. Wire trace
confirms ACE delivers the WalkForward → RunForward transition
correctly and SetCycle does fire for it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Multi-bug fix for the env-var-gated retail-faithful remote tick path
(ACDREAM_INTERP_MANAGER=1). Combines four previously-stacked defects
into one coherent rewrite:
1. PositionManager.ComputeOffset was additive (rootMotion + correction).
Retail's PositionManager::adjust_offset (acclient @ 0x00555190 →
InterpolationManager::adjust_offset @ 0x00555d30) REPLACES the
offset frame via Frame::operator=(arg2, &__return) when catch-up
engages — it does NOT add to the rootOffset that CPartArray::Update
wrote. Switched to "correction overrides root motion" semantics.
2. MotionInterpreter.GetMaxSpeed was returning RunAnimSpeed × rate
(~11.7 m/s for run skill 200). The retail decomp at
acclient_2013_pseudo_c.txt:305127 shows get_max_speed returns the
bare run rate (~2.94) — the function's float return rides the x87
FPU stack, which Binary Ninja shows as void. Caller multiplies by
2.0 to get the catch-up speed. With the wrong return our catch-up
was 23.5 m/s instead of retail's 5.88 m/s — the queue would walk
the body 4× too aggressively.
3. The env-var TickAnimations branch was DOUBLE-COUNTING forward
translation: it applied seqVel × dt via PositionManager.ComputeOffset
AND let UpdatePhysicsInternal advance body.Position += body.Velocity
× dt. Both were ~11.7 m/s for run, so body raced at 23.4 m/s —
"way too fast" per the user. Pass seqVel=Vector3.Zero to
ComputeOffset; let body.Velocity (refreshed per tick by
apply_current_movement) drive the bulk translation alone.
4. Body orientation only applied sequencer.CurrentOmega per tick. For
the running-in-circles case ACE broadcasts ForwardCommand=RunForward
AND TurnCommand=TurnLeft on the same UpdateMotion; the sequencer
picks the RunForward cycle whose synthesized CurrentOmega is zero,
so body never rotated between UPs and body.Velocity stayed in an
out-of-date world direction — the visible "rectangle when running
circles" effect. Prefer ObservedOmega (set explicitly in
OnLiveMotionUpdated from the wire's TurnCommand + signed TurnSpeed)
when present; fall back to seqOmega for standalone turn cycles.
Also adds:
- Sequencer-reset call in the env-var landing-fallback so the legs
un-fold from Falling on land (mirrors the legacy K-fix17 path).
- LastServerZ now only updates on IsGrounded UPs, so the per-tick
landing-fallback floor doesn't drift up to the player's airborne
peak Z and force-land mid-arc — fixes the user-reported "small
landing in the air before landing on the ground" when jumping
while moving.
- VEL_DIAG now samples at UP arrival with overlapping windows, plus
TURN_WIRE / OMEGA_DIAG / FWD_WIRE diagnostics gated on
ACDREAM_REMOTE_VEL_DIAG=1 used to trace these bugs to ground truth.
Verified via live retail-driven character observation 2026-05-03:
turn-left now rotates left (was animating right with snap), running
in circles is much smoother, jumping lands on ground (no mid-air
pause). Residual ~20% steady-state overshoot for walk remains —
WalkAnimSpeed=3.12 (decompiled retail constant) doesn't match ACE's
actual broadcast walk pace (~2.6 m/s). Tracked separately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The wire-arrival animCycle picker in OnLiveMotionUpdated was passing
MathF.Abs(turnSpeed) to the sequencer, stripping the sign that ACE uses
to encode TurnLeft. Confirmed via live wire trace 2026-05-03: TurnLeft
input from a retail-driven character arrives as
turnCmd16=0x000D (TurnRight), TurnSpeed=-1.500 — mirroring retail's
adjust_motion convention on the wire. With Abs, both directions
collapsed onto motion=TurnRight + speedMod=+1.5, and the synthesize-
omega path computed -2.25 (CW = right) for both. Visible symptom:
TurnLeft animated as TurnRight then blipped to the correct facing on
the next UpdatePosition.
Pass the signed speed through unchanged. The sequencer's negative-
speed path (EnqueueMotionData multiplies MotionData.Omega by speedMod;
the synthesize-omega fallback uses -(pi/2)*adjustedSpeed) produces the
correct CCW omega for TurnLeft now that the sign survives.
Also adds a TURN_WIRE diagnostic gated on ACDREAM_REMOTE_VEL_DIAG=1
that prints every wire-arrived TurnCommand with reconstructed enum
and signed speed, plus splits the OMEGA_DIAG throttle off
LastVelDiagLogTime onto its own LastOmegaDiagLogTime so the two
diagnostics don't starve each other.
Verified with the same trace: TURN_WIRE speed=-1.500 -> OMEGA_DIAG
Z=+2.250 (CCW = TurnLeft), TURN_WIRE speed=+1.500 -> OMEGA_DIAG
Z=-2.250 (CW = TurnRight). Both directions now have correct sign.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Visual verification of L.3.1-as-originally-scoped (commit ae79e34
through e08accf) revealed that InterpolationManager corrections alone
cannot produce smooth motion — retail also relies on animation root
motion (the L.3.2 PositionManager work, originally deferred). The two
halves are functionally inseparable.
Spec changes:
- L.3.1 sub-lane absorbs L.3.2's PositionManager
- New section: PositionManager architecture (pure-function ComputeOffset
returning Vector3 delta; combines body-local seqVel * dt rotated to
world + InterpolationManager.AdjustOffset correction)
- New section: IsGrounded plumbing through EntityPositionUpdate (the
PositionFlags.IsGrounded=0x04 is already parsed; just expose it)
- New section: retail-faithful jump pipeline (airborne → no-op per
MoveOrTeleport's has_contact=0 semantics; landing detected via first
IsGrounded=true UP after airborne)
- Acceptance criteria updated for combined scope
- Implementation order: 6 commits remaining (after the revert at 1641d6e)
- Stall-blip TAIL annotation (Task 0 resolution) folded in
L.3.3 (MoveToManager) stays a separate sub-lane.
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>
Code-quality review on commit 9c5634a flagged that the existing 4
GetMaxSpeed tests didn't cover the case where WeenieObj is null and
RunForward must fall back to MyRunRate. Without this test, a
regression that hardcoded the fallback to 1.0f would silently pass.
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>
Port retail's InterpolationManager + MoveOrTeleport routing into
acdream so remote players, creatures, and NPCs stop popping at every
server position update and instead glide smoothly between sparse
authoritative updates the way retail does.
Three sub-lanes (incremental, each visually verifiable):
- L.3.1 — InterpolationManager core + routing + Omega + soft-snap teardown
- L.3.2 — PositionManager (root-motion + interp-offset combiner)
- L.3.3 — MoveToManager (server-controlled creature MoveTo)
This commit specs L.3.1 in detail and sketches L.3.2/L.3.3.
Research baseline (cdb live-trace + named-decomp dive 2026-05-02)
captured in docs/research/2026-05-02-remote-entity-motion/
resolved-via-cdb.md. All key constants confirmed from binary, not
guessed: MAX_PHYSICS_DISTANCE=96, MAX_INTERPOLATED_VELOCITY_MOD=2.0,
MAX_INTERPOLATED_VELOCITY=7.5, MIN_DISTANCE_TO_REACH_POSITION=0.20,
DESIRED_DISTANCE=0.05, queue cap 20, stall window 5/30%/3.
Rollout: ACDREAM_INTERP_MANAGER=1 env-var gate during development
(dual-path), single cleanup commit after visual verification removes
the flag + old hard-snap path + dead RemoteMotion soft-snap fields.
Test plan: ~15 unit tests against the InterpolationManager class
(pure-data, no game/window deps). Visual verification primary —
parallel retail observer of +Acdream walking/running/strafing/
jumping/turning, all should glide.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the Phase M planning entry: replace the happy-path WorldSession
shape with a holtburger-aligned reliable network stack while keeping
acdream's stricter checksum verification + live ACE compatibility.
Lays out M.1–M.x sub-lanes (audit/parity map, layer extraction,
reliability core, etc.).
Detailed spec to land at
docs/superpowers/specs/2026-05-02-network-stack-conformance.md
before implementation starts. Holtburger is the client-behavior
oracle for this phase.
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>
Plan doc `docs/plans/2026-04-30-sky-pes-port.md` captures the full
porting plan for #36 (sky-PES dispatch chain). Three phases:
M.1 — decomp dive (no code yet) for CallPESHook::Execute,
CPhysicsObj::CallPES, CreateParticleHook::Execute,
GameSky::CreateDeletePhysicsObjects, and the dynamic-spawn
trigger (region/weather/time-of-day handler).
M.2 — optional cdb verification with detailed args (this pointer +
pes_id + caller stack walk).
M.3 — implementation: persistent emitter creation at cell load,
dynamic spawn on transitions, PES script-timeline driver,
particle-system render wire-up.
M.4 — live side-by-side verification.
Acceptance: aurora visible at right moments, clouds dense like retail,
storm flashes during Rainy storm windows, PES dispatch rate matches
retail's ~150/min.
.gitignore extended to suppress per-session retail-debugger scratch
files (cdb scripts, launch logs, analysis ps1 helpers). The
canonical workflow lives in CLAUDE.md "Retail debugger toolchain";
session-specific traces should not pollute the repo.
Closes#36 plan stage. Implementation work begins next session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A 2026-04-30 cdb live trace of retail acclient.exe attached during
sky observation revealed the missing infrastructure behind acdream's
three open sky bugs (#2 lightning, #28 aurora, #29 cloud density).
Live trace over 24,576 GameSky::Draw frames:
GameSky::Draw = 24,576 (60 Hz render rate)
GameSky::UseTime = 12,288 (30 Hz — half rate)
GameSky::CreateDeletePhysicsObjects = 12,288 (30 Hz, MinQuantum gate)
CPhysicsObj::CallPES = 372 (~150/min average)
CallPESHook::Execute = 372 (1:1 with CallPES)
CreateParticleHook::Execute = 62 (15 initial + 47 burst)
CPhysicsObj::create_particle_emitter = 62 (matches CreateParticleHook)
Three concrete findings:
1. Retail HAS persistent particle emitters on celestial / sky objects.
15 created at cell load; dynamically spawned on region/weather/time
transitions (the trace caught a +47 burst on one such transition).
2. The PES script-hook system drives existing emitters periodically.
`CallPESHook::Execute` fires script-scheduled actions which call
`CPhysicsObj::CallPES` 1:1, ~150 times per minute.
3. Earlier research saying "GameSky doesn't read pes_id" was correct
in scope but missed that the dispatch chain runs through the
script-hook system (not from inside GameSky directly).
`CelestialPosition.pes_id` is consumed downstream.
Files a new issue #36 that consolidates implementation work for
all three sky bugs into one porting effort. Adds cross-references
in #2, #28, #29 pointing at #36.
Decomp anchors for next session:
CallPESHook::Execute @ 0x00526e20
CreateParticleHook::Execute @ 0x00526ec0
CPhysicsObj::CallPES @ 0x00511af0
CPhysicsObj::create_particle_emitter @ 0x0050f360
GameSky::CreateDeletePhysicsObjects @ 0x005073c0
CelestialPosition.pes_id @ struct offset +0x004
Memory file `project_retail_debugger.md` updated separately with the
sky-PES breakthrough so future sessions inherit the context.
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>
Adds a "Communication style" section after "How to operate" that documents
how to discuss spatial / physics / animation / dat-format / protocol
topics with the user. The user has asked repeatedly for plain-language
framing of new concepts: name the idea in English first, then introduce
the term of art; give units (degrees, meters) instead of raw floats;
use analogies for spatial concepts (BSP = nested rooms, contact plane =
imaginary floor, sphere sweep = rolling a ball, dot/cross products);
walk through control flow frame-by-frame; flag terms of art the first
time they appear.
The goal is collaborative learning, not dumbed-down content.
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>
Formalize Phase L.2 as the active holistic movement/collision program, align the roadmap and architecture docs, file tactical physics follow-ups, and refresh collision memory away from rewrite-from-zero guidance.
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>