Commit graph

113 commits

Author SHA1 Message Date
Erik
833d167ebc fix(scenery): #49 9×9 loop, per-spawn building check, triangle slope
Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):

1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
   cells. Edge vertices (x=8 or y=8) produce valid spawns when the
   per-object displacement shifts the position back into [0, 192).
   Confirmed by named retail decomp do-while condition, WorldBuilder
   vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].

2. Building suppression: check at the DISPLACED position's cell
   (CSortCell::has_building per spawn), not at the loop vertex index.
   Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.

3. Slope filter: replace finite-difference gradient approximation
   with triangle-aware normal sampling via new static method
   TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
   triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
   polygon->plane.N.z and WorldBuilder's GetNormal().

Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 21:15:11 +02:00
Erik
a4693954d8 fix(scenery): #48 unify scenery Z with physics-path triangle picker
Closes #48. Trees on sloped cells visibly hovered above the visible
terrain because GameWindow.SampleTerrainZ (the bilinear fallback used
during scenery hydration before physics registers a landblock) had
its diagonal arms swapped — used the SEtoNW triangle test on SWtoNE
cells and vice versa. The ACDREAM_DUMP_SCENERY_Z=1 diagnostic showed
every scenery line ran through the bilinear path (streaming race),
so on hilly terrain scenery was placed at a Z up to ~1.5 m off from
the visible mesh.

Latent since ff325ab (2026-04-17 "feat(ui): debug overlay + refined
input controls" carrying along the upgrade). That commit reached for
WorldBuilder TerrainUtils.GetHeight as the secondary oracle and
re-derived the triangle-pair tests; the named-retail / ACE algorithm
in TerrainSurface.SampleZ (used by the physics path / player Z) was
always correct, so player feet stayed flush — the two paths just
disagreed and only scenery noticed.

Fix:
- TerrainSurface.InterpolateZInTriangle (private static) — single
  source of truth for the triangle pick + barycentric Z, sourced
  from FUN_00532a50 / ACE LandblockStruct.ConstructPolygons.
- TerrainSurface.SampleZFromHeightmap (public static) — heightmap-
  byte-array variant for the scenery hydration fallback. Both this
  and TerrainSurface.SampleZ (instance) now delegate to the same
  InterpolateZInTriangle.
- GameWindow.SampleTerrainZ — thin wrapper over the new static.
- TerrainSurfaceTests.SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock
  asserts both sampler paths agree at 1500 sample points across both
  diagonals, so future drift gets caught.

The ACDREAM_DUMP_SCENERY_Z=1 diagnostic in BuildSceneryEntitiesForStreaming
is kept committed (env-var gated, zero cost when off) — useful for
the related #49 scenery (X, Y) placement investigation filed in the
same commit.

Visual verified at Holtburg landblock 0xA9B30001 2026-05-06: the
formerly floating 32 m pines (setups 0x020002D3 / 0x020002D9) now
sit flush on the visible terrain mesh.

Test baseline: dotnet test reports the same 8 pre-existing motion /
BSP step-up failures as the handoff doc warned about — no new
failures introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:30:25 +02:00
Erik
bb026b7991 diag(motion): #39 — per-tick [CURRNODE] for sequencer node identity
Visual-verify of fix #2 (commit 863d96b) showed [SCFULL] correctly reports
currNodeIsCyclic=True after each direct Walk↔Run SetCycle (the link is
removed and _currNode is set to _firstCyclic). User report still:

- Animation continues running visually after Shift toggle to Walk
- Body slows ("speed decreases"), causing rubber-banding
- Adding a turn motion in that state makes the cycle finally transition
  to walking

So either:
- _currNode is reset to a stale node BETWEEN SetCycle and Advance
- _currNode is correctly on the new cycle but its AnimRef is wrong
  (e.g., the same Animation as the previous cycle, dat-side issue)
- BuildBlendedFrame reads from somewhere other than _currNode

Adds CurrentNodeDiag + FirstCyclicAnimRefHash properties on
AnimationSequencer that expose the active node's Animation
identity-hash, IsLooping, Framerate, frame range, and FramePosition.
TickAnimations logs them on every SEQSTATE tick (1 Hz throttle, gated
on ACDREAM_REMOTE_VEL_DIAG=1).

The [CURRNODE] line with animRef vs firstCyclicAnimRef proves whether
_currNode is actually on the new cycle's anim or has drifted to
something else. Compared across SetCycle SCFULL log lines + the
following CURRNODE ticks, we can see the exact moment the cycle
diverges from what SetCycle set.

No code-behavior changes. Pure read-only instrumentation. Per
Phase 4.5 of systematic-debugging — STOP attempting fixes; gather
evidence first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:17:56 +02:00
Erik
863d96bb23 fix(motion): #39 — skip transition link for cyclic→cyclic locomotion in SetCycle
Root cause identified by research-agent read of AnimationSequencer.SetCycle
+ Advance + the per-tick TickAnimations call site:

- SetCycle enqueues transition link + new cycle, then forces _currNode
  onto firstNew (the LINK), per the 357dcc0 fix that pinned _currNode
  to the most-recently-enqueued node.
- Advance plays the link to completion (~100–300 ms at Framerate 30 ×
  link runSpeed) before AdvanceToNextAnimation moves _currNode forward
  to the cycle.
- For Walk↔Run direct toggles faster than the link's drain time, the
  next UM arrives, SetCycle restarts _currNode on a fresh link, and
  the cycle node at the queue tail is never reached.
- BuildBlendedFrame returns frames from the link the entire time —
  user observes the link's interpolation pose ("blips forward in
  walking animation"), never the new Walk or Run cycle.

Confirmed by [SCFULL] currNodeIsCyclic=False after every direct
Walk↔Run transition in launch-39-candidate.log.

Fix: when prev motion AND new motion are both locomotion cycles
(WalkForward, WalkBackward, RunForward, SideStep L/R), land
_currNode on _firstCyclic (the new cycle node) instead of firstNew
(the link), and remove the just-enqueued link from the queue.
Conditional on BOTH being locomotion to avoid regressing cases that
DO need the link to play:
- Idle→Run (link is the wind-up pose)
- Falling→Ready (landing animation)
- Ready→Sitting/Crouching/Sleeping
- Combat substates (attack/parry/ready transitions)

Reverted commit c06b6c5 demonstrated that unconditional link skip
breaks all of those — this fix is narrower.

Retail reference: cdb live trace 2026-05-03 of a Walk→Run direct
transition logged add_to_queue(45000005) followed by
add_to_queue(44000007) with truncate_animation_list never firing —
matching the observed semantics this fix implements.

42/42 AnimationSequencer tests pass. The 8 pre-existing test
failures elsewhere on the branch (BSPStepUp, MotionInterpreter
WalkBackward, etc.) are unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 07:28:21 +02:00
Erik
9e4772a8f8 fix(motion): project anim root motion onto terrain plane (slope staircase)
Grounded player remotes were showing a ~5 Hz Z staircase when running
up/down slopes — the rate of server UpdatePositions. Body Z stayed flat
between UPs, then ramped over ~100ms during the queue-active chase to
each new server position, then went flat again until the next UP.

Diagnosis (no diagnostic needed — the math is unambiguous):
PositionManager.ComputeOffset has two modes via
InterpolationManager.AdjustOffset:

  - Queue active (body chasing a waypoint): returns
    `(head − body) / dist × min(catchUpSpeed × dt, dist)`. 3D direction,
    Z follows server's reported Z naturally.
  - Queue empty / head-reached (within DESIRED_DISTANCE = 0.05m of the
    most recent UP): returns Vector3.Zero. ComputeOffset falls back to
    `seqVel × dt rotated into world` — pure animation root motion. Every
    locomotion cycle bakes Z=0 in body-local, so the world result has
    Z=0 too. XY advances at the running pace; Z stays at the last UP.

For a runner at maxSpeed ≈ 4 m/s with catchUpSpeed = 2× = 8 m/s and
server UPs at ~5 Hz, body covers ~0.8m per UP, chases for ~100ms
(queue-active 3D path, Z ramps), then sits in seqVel-only mode for
~100ms (Z flat) until the next UP. Visible as a 5 Hz Z staircase.

Fix mirrors retail's CTransition::adjust_offset contact-plane projection
(named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
motion, applied at the queue-empty boundary instead of inside the sweep:

  PositionManager.ComputeOffset gains an optional Vector3? terrainNormal.
  When the seqVel-only fallback runs AND a non-trivial terrain normal is
  supplied, project rootMotionWorld onto the plane:

      result = rootMotionWorld − N × dot(rootMotionWorld, N)

  Anim XY motion gains a corresponding Z component proportional to slope
  angle × forward speed, so body Z follows the terrain mesh between UPs.
  No-op on flat ground (N ≈ +Z, dot ≈ 0); cannot regress L.3 M2's
  flat-ground verification.

GameWindow.TickAnimations grounded-remote path samples
PhysicsEngine.SampleTerrainNormal at the body's current XY each tick
and passes it to ComputeOffset. SampleTerrainNormal is a thin public
wrapper over the existing internal SampleTerrainWalkable that returns
just the plane normal (no need to expose the internal sample shape).

Diagnostic: ACDREAM_SLOPE_DIAG=1 prints a per-tick [SLOPE] line with
guid, body Z before/after, offset, queue active flag, and the sampled
plane Nz so we can grep before/after the fix and confirm Z changes
continuously between UPs on slopes.

Tests: PositionManagerTests gains two cases:
  - slope projection: 30° east-tilted plane, body running due east at
    4 m/s for 1s → expect (3.0, 0, −1.732) (descends along slope, not
    flat). Math: dot(seqVel, N) = 2.0 → result = (4,0,0) − (0.5,0,0.866)
    × 2.0 = (3.0, 0, −1.732).
  - flat-ground no-op: N = +Z, expect identical Y-only motion as the
    pre-fix behavior.

Build green. 357 pass / 6 pre-existing fail (same set as ec59a08;
verified by stashing this change). The pre-existing
`ComputeOffset_BothActive_Combined` failure reflects an outdated
additive-design test docstring; the M2 commit (40d88b9) deliberately
changed the implementation to REPLACE semantics to fix the prior
3×-server-pace overshoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:37:42 +02:00
Erik
ec59a08db5 fix(physics): #42 skip self in FindObjCollisions — airborne XY drift
Root cause confirmed via two-run diagnostic and the named-retail decomp:
the airborne sweep was colliding with the moving entity's OWN ShadowEntry
because FindObjCollisions had no self-skip filter. Live entities (local
player, remotes) register a Cylinder in ShadowObjectRegistry on spawn
(GameWindow.cs:2545) and UpdatePosition tracks its world position each
tick, so the moving sphere's own cylinder is always at the body's
position. Without a gate, CylinderCollision sees the sphere overlapping
its own cylinder volume and slides the sphere ~1m horizontally on every
frame the path produces non-zero motion.

Why grounded mostly hides it and airborne exposes it:
- Stationary grounded → numSteps=0, TransitionalInsert never runs.
- Walking grounded → push fires but motion escapes the cyl radius and
  the deflection blends into normal motion.
- Stationary airborne (jump) → pure +Z motion; the cyl push is the
  only horizontal contribution and manifests as a clean ~1m drift.

Run-2 evidence (launch-42-r2.log) — 152 [SWEEP-OBJ] events, every one
with type=Cylinder, gfxObj=0x02000001 (humanoid setup), R=0.679,
H=1.835, at obj.Position EXACTLY matching the body's pre.Position. Run
1 had already ruled out H1 (cpN=(0,0,1) flat, no slope projection).

Retail does the same skip — CObjCell::find_obj_collisions at
named-retail acclient_2013_pseudo_c.txt:308931:

    if ((physobj->parent == 0 && physobj != arg2->object_info.object))

`arg2->object_info.object` is the OBJECTINFO::object self-pointer set
by OBJECTINFO::init at acclient_2013_pseudo_c.txt:274435. Our port
mirrors this with an EntityId-based filter:

  - ObjectInfo gains a SelfEntityId field (default 0 = no filter).
  - ResolveWithTransition gains an optional `uint movingEntityId = 0`
    parameter that sets it.
  - FindObjCollisions skips entries whose EntityId matches
    SelfEntityId when the id is non-zero.
  - PlayerMovementController gains a LocalEntityId property; GameWindow
    refreshes it per-tick from `_entitiesByServerGuid[_playerServerGuid]`.
  - GameWindow's airborne-remote ResolveWithTransition call site passes
    `movingEntityId: kv.Key` (kv.Key is the local entity id keying
    `_animatedEntities`, same id used at the spawn-time
    ShadowObjects.Register).

Default 0 keeps tests and one-shot callers (no registered ShadowEntry)
working unchanged.

Lock-the-fix unit test:
`PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches`
registers a humanoid Cylinder at the body's exact position (matching
GameWindow's spawn pattern), then asserts that:
  - movingEntityId=0 (control)        → unfiltered XY drift > 0.5m
  - movingEntityId=registered id (fix) → XY drift ≈ 0

Diagnostic wiring (a36369d + this commit's [SWEEP-OBJ] addition) stays
in tree, env-var gated (ACDREAM_AIRBORNE_DIAG=1) so it produces no
output in normal use but lets us verify the fix on the live client and
debug future regressions.

Build: green. Tests: 355 pass, 6 fail (all pre-existing per the handoff
prompt — verified by stashing this change; the BSPStepUp C3 failure is
on the prior commit too).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:01:07 +02:00
Erik
a36369d8ca diag(physics): #42 add ACDREAM_AIRBORNE_DIAG [SWEEP] trace
Phase 1 of #42 root-cause investigation per the handoff doc. We
A/B confirmed (commit b37b713) that the ~1m XY drift on retail-
observed stationary jumps comes from inside ResolveWithTransition
when the per-tick airborne sweep runs (CellId fix at GameWindow.cs
3467). What we don't yet know: whether the drift originates in
H1 (initial-overlap depenetration along a tilted-terrain normal),
H2 (step-down probe firing despite isOnGround=false), or H3
(EdgeSlide on near-vertical motion grazing a wall).

This diagnostic gates a one-line Console trace on
ACDREAM_AIRBORNE_DIAG=1 AND !isOnGround so it doesn't pollute
grounded movement, and prints:

  [SWEEP] airborne pre=(...) target=(...) post=(...)
          cell=PRE->POST ok=BOOL deltaXY=(dx,dy)
          cp=valid|none cpN=(nx,ny,nz)

deltaXY = post - target — for a clean stationary +Z jump we
expect (0,0). Non-zero with cp=valid and a tilted cpN confirms
H1; non-zero direction tracking actor facing instead of terrain
orientation points to H2/H3.

Code-walk findings recorded for the next investigation pass:
- K-fix7 already prevents seeding ContactPlane on entry for
  airborne (PhysicsEngine.cs:493-519), so step 0's AdjustOffset
  cannot consume a stale plane.
- BUT ValidateWalkable can still SET ContactPlane during step 0's
  collision pass via the "below plane" branch (TransitionTypes.cs
  1320-1352) when sphere lowPoint dips below the tilted terrain
  triangle. Step 1's AdjustOffset would then consume that fresh
  plane and the "moving away from contact plane" branch
  (TransitionTypes.cs:1749-1754) projects the +Z offset along the
  slope normal, redirecting Z motion into XY.
- Step-down branch is correctly gated on oi.Contact (matches
  retail CTransition::transitional_insert at named-retail
  acclient_2013_pseudo_c.txt:273249, "(state & 1) == 0" returns
  OK without firing step-down).
- Retail's IS_VIEWER_OI=0x4 branch in OBJECTINFO::validate_walkable
  (acclient.h:6185) is never set anywhere in the named decomp,
  so the airborne path runs the same code in retail as in acdream.

User repros at flat plaza / east hillside / north hillside; the
direction-correlation of deltaXY with local terrain orientation
identifies which hypothesis is firing.

Build green; 13 PhysicsEngine tests green. No behavior change
when ACDREAM_AIRBORNE_DIAG is unset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:16:29 +02:00
Erik
de129bc164 feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec
in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API
preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive,
Count) so PositionManager + GameWindow callers continue to compile;
internals are full retail spec.

Bug fixes vs prior port (audit 04-interp-manager.md § 7):

  #1  progress_quantum accumulates dt (sum of frame deltas), not step
      magnitude. Retail line 353140; the prior port's `+= step` made
      the secondary stall ratio meaningless.

  #3  Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets
      _failCount = StallFailCountThreshold + 1 = 4, so the next
      AdjustOffset call's post-stall check fires an immediate blip-to-
      tail snap. Retail line 352944. Prior port silently drifted
      toward far targets at catch-up speed instead of teleporting.

  #4  Secondary stall test ports the retail formula verbatim:
      cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE.
      Audit notes the units are 1/sec (likely Turbine bug or x87 FPU
      misread by Binary Ninja) — mirrored byte-for-byte regardless.

  #5  Tail-prune is a tail-walking loop, not a single-tail compare.
      Multiple consecutive stale tail entries within DesiredDistance
      (0.05 m) of the new target collapse together. Retail line 352977.

  #6  Cap-eviction at the HEAD when count reaches 20 (already correct
      in the prior port; verified).

New API: Enqueue gains an optional `currentBodyPosition` parameter so
the far-branch detection can reference the body when the queue is
empty. Backward-compatible (default null = pre-far-branch behavior).

UseTime collapsed into AdjustOffset's tail (post-stall blip check)
since acdream has no per-tick UseTime call separate from
adjust_offset; identical semantic outcome.

State fields renamed to retail names with sentinel values:
  _frameCounter, _progressQuantum, _originalDistance (init = 999999f
  sentinel per retail line 0x00555D30 ctor), _failCount.

Tests:
- 17/17 InterpolationManagerTests green.
- New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset
  pins the bug #3 fix: enqueueing 150 m away triggers a same-tick
  blip (delta length ≈ 150 m), and the queue clears.

Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/.
00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick,
03-up-routing, 04-interp-manager, 05-position-manager-and-partarray,
06-acdream-audit, 14-local-player-audit are the L.3 spec used by this
commit and the M2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:56:42 +02:00
Erik
a3f53c2644 docs+cleanup: env-var regression + Run↔Walk cycle bug filed; re-throttle diags
End-of-session cleanup of the 2026-05-03 remote-motion debug session.

Documentation:

- CLAUDE.md: add ⚠️ DO-NOT-ENABLE warning for ACDREAM_INTERP_MANAGER=1
  in the diagnostic env-vars list. Add an "Outbound motion wire format"
  section documenting acdream's WalkForward+HoldKey.Run encoding (which
  ACE auto-upgrades to RunForward on relay) so future sessions don't
  re-derive it.

- docs/ISSUES.md: file two new issues:
  * #39 — Run↔Walk cycle transition not visible on observed
    retail-driven player remotes when watched from acdream. Root cause
    located: ApplyServerControlledVelocityCycle is gated by
    IsPlayerGuid, excluding the exact case where ACE doesn't broadcast
    a UM (shift toggle while direction key held). Fix sketch ~10
    lines, separate commit. Cross-references the four-agent
    investigation prompt.
  * #40 — ACDREAM_INTERP_MANAGER=1 env-var path regressed. Documents
    why (e94e791 conflated MoveOrTeleport with update_object), the
    visible symptoms (staircase Z, position blips), and why
    Commit B (039149a)'s ResolveWithTransition port was insufficient
    (env-var path also clears body.Velocity → no horizontal Euler
    motion → sweep input is queue catch-up only, which stair-steps).
    Fix path = separate L.3 follow-up to re-integrate PositionManager
    additively on top of the legacy chain.

Code:

- GameWindow.cs:6042: prepend a ⚠️ REGRESSED warning block at the top
  of the env-var per-frame branch so anyone reading the code is
  immediately aware not to enable it. Cross-references ISSUES.md #40.

- AnimationSequencer.cs: re-throttle [SCFAST]/[SCFULL] diagnostics to
  0.5s per instance (rolled back from A.1's unthrottled experiment).
  Already served its purpose; throttled is enough for steady-state
  diagnostics. Restore _lastSetCycleDiagTime field.

No behavior change for any current launch (env-var unset = legacy
path unchanged). Build green; baseline test failures (8) unchanged
from prior commit, none introduced by this session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 10:10:10 +02:00
Erik
eaa8fc5c67 diag(motion): A.1 — unthrottled SCFAST/SCFULL + UM_RAW (Commit A.1)
Commit A's log refuted H2 (UPCYCLE never fires for player guids — gated
by IsPlayerGuid), H4 (SCNULLFALLBACK count = 0), H5 (PartTemplate
counts always match anim PartFrames). The remaining puzzle:

  SCFULL Ready=23 dominates (all motions: 41 total)
  SETCYCLE picker logged: only 9 transitions to Ready

Difference (≥14 extra Ready full-rebuilds) suggests a non-picker source,
OR many UMs arriving with no ForwardCommand bit being routed through
the picker's `else if (!command.HasValue) { fullMotion = Ready; }` at
GameWindow.cs:2671-2673, knocking the cycle back to Ready mid-Walk/Run.

This commit removes the 0.5s throttle on SCFAST and SCFULL (every call
now logs) and adds [UM_RAW] at OnLiveMotionUpdated entry to print:
  - stance / fwd / fwdSpd / side / turn / movementType / isMoveTo
  - sequencer.CurrentMotion at call time
per UM, gated on ACDREAM_REMOTE_VEL_DIAG=1.

Combined: one repro pass tells us (a) UM arrival rate per remote, (b)
which UMs lack ForwardCommand, (c) whether the picker is the source of
the 14+ extra Ready calls. Commit B is then a one-line fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 20:51:14 +02:00
Erik
23004a4791 diag(motion): instrumentation for remote walk↔run leg-cycle bug (Commit A)
Adds five diagnostics, no behavior changes. All gated on existing
ACDREAM_REMOTE_VEL_DIAG=1 env var. Plan at
~/.claude/plans/yes-make-a-plan-parsed-axolotl.md.

Five hypotheses surviving from the four-agent investigation
(docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md):

  H1  SEQSTATE silently swallowed by OMEGA_DIAG sharing throttle clock
  H2  ApplyServerControlledVelocityCycle races UM-driven SetCycle per UP
  H3  SetCycle fast-path returns without updating _currNode
  H4  GetLink/GetCycle null → defensive fallback lands on stale head
  H5  PartTemplate.Count diverges from anim PartFrames.Count → silent
       identity-quat freeze

Diagnostics added (all log lines are grep-prefixed):

  D1  Split LastSeqStateLogTime field for SEQSTATE — own throttle.
      Foundational: every other diag depends on SEQSTATE telling truth.
  D2  [UPCYCLE] inside ApplyServerControlledVelocityCycle, +
      [UPCYCLE_SRC] at the call site (wire vs synth velocity).
  D3  [SCFAST] in fast-path return, [SCFULL] at full-rebuild end.
  D4  [SCNULLFALLBACK] in the null-data defensive fallback.
  D5  [PARTSDIAG] with pt.Count / seqFrames.Count / setup.Parts.Count /
       anim.PartFrames[0].Frames.Count + sum-of-components hash.

Repro recipe:

  $env:ACDREAM_INTERP_MANAGER  = "1"
  $env:ACDREAM_REMOTE_VEL_DIAG = "1"
  dotnet run … 2>&1 | Tee-Object tools/diag-logs/walkrun-<ts>.log

Then watch a retail-driven character through acdream and exercise:
idle → W run → release → shift+W walk → release → demote → promote →
run+turn (this last one is the H1 trap).

Decision matrix in the plan file maps each [TAG] signature to a
specific Commit B fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 20:38:47 +02:00
Erik
357dcc0547 fix(motion): SetCycle forces _currNode onto first newly-enqueued node;
skip SubState commands in UM Commands list iteration

Two related fixes for the "remote-driven character animation cycle
does not visibly switch" bug:

1. AnimationSequencer.SetCycle now snapshots _queue.Last BEFORE
   appending the new link/cycle nodes, then forces _currNode onto
   preEnqueueTail.Next (= first newly-added node). Without this,
   _currNode could stay pointing into stale non-cyclic head frames
   left over from the previous cycle (typically a Walk_link or
   Ready_link's tail), and the visible animation continues playing
   those stale frames before the queue advances naturally to the
   new cycle. Local player avoided the bug because
   PlayerMovementController fires SetCycle in a tight per-input loop
   that keeps the queue clean; remote player accumulates stale
   link drains across many bundled UMs.

2. OnLiveMotionUpdated's UM Commands list iteration now skips
   SubState class commands (high byte 0x40-0x4F like Ready
   0x41000003). The router's SetCycle call for those would silently
   override the animCycle picker's own SetCycle a few lines above
   in the same UM packet — verified via SETCYCLE diag captures
   showing run/walk being immediately re-cycled to Ready. Only
   Action / Modifier / ChatEmote class commands (overlays that
   interleave with the cycle) belong in this list iteration.

This fixed the landing-from-jump animation issue (user-confirmed:
"landing now works"). Walk↔run direct transitions still don't
visibly switch the leg cycle for observed retail-driven characters
even though ae.Sequencer.CurrentMotion correctly transitions
(per-tick SEQSTATE diag added — proves the sequencer's logical
state holds the right motion). Bug is somewhere downstream of
SetCycle's queue/state setup, possibly in Advance/BuildBlendedFrame
or in how seqFrames are applied to MeshRefs for remote entities.
Filed for next investigation.

Adds env-var-gated diagnostics (ACDREAM_REMOTE_VEL_DIAG=1):
  CMD_LIST   — what's in the UM's Commands list at receive time
  HASCYCLE   — whether the requested cycle exists in the dat
  SEQSTATE   — per-tick sequencer.CurrentMotion + CurrentSpeedMod
               for the observed retail char (1Hz throttled)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 19:54:54 +02:00
Erik
a2ae2aefcc revert: AnimationSequencer locomotion-cycle full-reset and link-skip
Both changes were too aggressive:

1. Full queue reset on locomotion-locomotion transitions (c06b6c5)
   — turned out the user's tests went through Ready (no direct
   walk↔run transitions in the wire), so the fix never fired
   and didn't address the actual bug.

2. Unconditional skip of every transition link
   — killed ALL transition animations across the board (jump
   landing, run-to-stop, sit-down, lie-down, etc.) for every
   entity, not just the locomotion-locomotion case. User
   correctly identified this as a much bigger regression.

Sequencer is back to pre-c06b6c5 baseline: ClearCyclicTail-only
on motion change, transition link enqueued normally. The
walk↔run-direct-transition issue (and the broader
remote-only-doesn't-update issue) remains open and requires a
different approach.

Confirmed regression isolation: local +Acdream's transitions in
acdream client work (visible legs switch correctly), and acdream
chars observed from a parallel retail client also have working
transitions. The bug is specifically when acdream observes a
RETAIL-driven character — somewhere in the inbound
UpdateMotion → animCycle picker → SetCycle path, the visible
cycle update is being lost. Filed for separate investigation.

Adds an env-var-gated HASCYCLE diagnostic in OnLiveMotionUpdated
that confirmed cycle resolution succeeds (HasCycle=True for both
RunForward 0x44000007 and WalkForward 0x45000005 on style
0x8000003D), so the bug isn't in MotionTable cycle lookup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:35:55 +02:00
Erik
c06b6c51e1 fix(motion): full queue reset on locomotion-cycle direct transitions
When AnimationSequencer.SetCycle transitions between forward-locomotion
cycles (Walk↔Run, Walk↔WalkBackward, etc.) — i.e. when both old and new
motion's low byte is in {0x05 WalkForward, 0x06 WalkBackward, 0x07
RunForward} — do a full queue drain + _currNode/_firstCyclic reset
(matching the existing skipTransitionLink branch) instead of just
ClearCyclicTail. Without this, _currNode is left pointing into the
previous cycle's non-cyclic head (link frames from the prior Ready→walk
transition), and the visible legs continue playing those head frames
before reaching the new run cycle.

Investigation findings (cdb live trace of retail at
tools/cdb-scripts/walk_run_motion_trace.log):

  Retail's actual approach is "additive add_to_queue with no truncate" —
  MotionTableManager handles the natural progression via per-tick
  CheckForCompletedMotions / remove_redundant_links cleanup. Acdream
  doesn't have that machinery, so this fix is the closest viable
  emulation: force the queue back to a clean state and rebuild from
  scratch on the locomotion-cycle transition.

User-reported symptom this addresses (walk→run direct transition,
release shift while W held): visible animation cycle did not switch
until next motion event. Verified via FWD_WIRE + SETCYCLE diags that
both ACE and our SetCycle are firing correctly on the transition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:00:55 +02:00
Erik
a45c21ee51 fix(motion): retail-faithful remote tick — clear body.Velocity, drive via seqVel
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>
2026-05-03 16:23:57 +02:00
Erik
842dfcd092 fix(motion): retail-faithful per-frame remote tick (L.3.2 follow-up)
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>
2026-05-03 15:24:24 +02:00
Erik
0997f96078 fix(motion): landing fallback + TurnLeft omega sign + vel diagnostic (L.3.2)
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>
2026-05-03 10:48:10 +02:00
Erik
08fbbef3c4 feat(physics): PositionManager combiner class + 6 unit tests (L.3.2)
Pure-function ComputeOffset(dt, pos, seqVel, ori, interp, maxSpeed) →
Vector3. Combines animation root motion (seqVel × dt rotated by body
orientation) with InterpolationManager.AdjustOffset world-space
correction. Mirrors retail CPhysicsObj::UpdateObjectInternal
(acclient @ 0x00513730).

Composed into RemoteMotion in subsequent task (L.3.1+L.3.2 Task 3);
not yet consumed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:13:02 +02:00
Erik
9c5634af17 feat(physics): MotionInterpreter.GetMaxSpeed for InterpolationManager (L.3.1 Task 2)
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>
2026-05-02 19:16:38 +02:00
Erik
927636ec77 fix(physics): InterpolationManager review findings (L.3.1 Task 1 polish)
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>
2026-05-02 19:10:23 +02:00
Erik
f43f168916 feat(physics): InterpolationManager core (L.3.1 Task 1)
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>
2026-05-02 19:00:17 +02:00
Erik
17a9ff1158 fix(motion): jump direction, AutoPos cadence, backward/strafe wire & anim
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>
2026-05-02 16:11:15 +02:00
Erik
235de3322a feat(physics): #32 L.5 30Hz physics tick + retail debugger toolchain (#35) + Phase 3 retail-faithful kill_velocity
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>
2026-04-30 22:41:12 +02:00
Erik
b1af56eb19 fix(physics): L.4 — steep airborne hits slide-tangent (interim, deviates from retail)
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>
2026-04-30 13:22:07 +02:00
Erik
a48883af2d fix(physics): L.4-cliffslide-priority — steep ContactPlane check before OnWalkable gate
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>
2026-04-30 10:29:30 +02:00
Erik
52e257d8d7 fix(physics): L.4-cliffslide-gate — fire CliffSlide on steep ContactPlane, not just on invalid
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>
2026-04-30 10:24:24 +02:00
Erik
1abb699c68 docs(physics): L.3c attempt — friction threshold investigation, deferred
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>
2026-04-30 09:46:42 +02:00
Erik
a1c27b3afb feat(physics): L.3a — wall-bounce velocity reflection on airborne hits
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>
2026-04-30 09:41:04 +02:00
Erik
261322b48e fix(physics): #32 L.2c precipice edge-slide context
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>
2026-04-30 08:04:37 +02:00
Erik
1ec40f2a4f fix(physics): #32 L.2c wire edge-slide movement flag 2026-04-30 07:40:43 +02:00
Erik
9fea9b13ad fix(physics): #31 update outdoor cell id during transition movement 2026-04-29 22:00:30 +02:00
Erik
e44d24cec6 fix(physics): L.2.3i — use FloorZ (not LandingZ) for OnWalkable test
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>
2026-04-29 19:28:30 +02:00
Erik
4cbfe0a5f8 fix(physics): L.2.3h — skip Placement in step-down contact-recovery branch
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>
2026-04-29 19:13:56 +02:00
Erik
eed8e8ccaa diag(physics): L.2.3g — log step-up SUCCESS/FAIL + landing plane
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>
2026-04-29 18:13:32 +02:00
Erik
8fe178ee5c fix(physics): L.2.3d/e/f — wall slide, edge block, step-up diagnostic
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>
2026-04-29 17:56:22 +02:00
Erik
d2f6067960 fix(physics): L.2.3c — preserve contact plane through failed step-up
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>
2026-04-29 17:24:49 +02:00
Erik
3789491394 fix(physics): L.2.3b — Path 5 step-up recursion guard
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>
2026-04-29 17:24:12 +02:00
Erik
670f892bd3 feat(physics): Phase L.2.1+L.2.2 — BSP step-up and rooftop landing
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>
2026-04-29 16:16:39 +02:00
Erik
b0c29454d0 test(physics): conformance fixtures for BSP step-up + roof-landing (Phase L.2.0)
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>
2026-04-29 15:44:16 +02:00
Erik
7d6fe90607 feat(physics): retail PvP exemption + viewer/creature/missile gates (Commit C)
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>
2026-04-29 13:21:36 +02:00
Erik
ffefc6977f feat(physics): live-entity collision plumbing (Commit A)
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>
2026-04-29 13:12:56 +02:00
Erik
34d7f4def2 fix(anim): Phase L.1c sequencer cycle fallback for missing MoveTo motion
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>
2026-04-29 10:33:48 +02:00
Erik
ff6d3d0c94 fix(anim): Phase L.1c clamp approach velocity to prevent overshoot
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>
2026-04-29 10:14:35 +02:00
Erik
f794832ebc fix(anim): Phase L.1c clear MoveTo state + bulk-copy ForwardCommand on overlay UMs
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>
2026-04-29 10:02:53 +02:00
Erik
d247aef2e4 fix(anim): Phase L.1c chase arrival + stale destination
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>
2026-04-29 08:32:54 +02:00
Erik
186a584404 feat(anim): Phase L.1c port MoveTo path data + per-tick steer
Root-causing the user-reported "monsters disappearing some time +
laggy/jittery locomotion" via systematic-debugging Phase 1: our
UpdateMotion parser kept only speed/runRate/flags from a movementType
6/7 packet and discarded Origin (destination), targetGuid, and the
distance/walkRunThreshold/desiredHeading half of MovementParameters.
The integrator consequently held Body.Velocity at zero during MoveTo
("incomplete state" stabilizer 882a07c), so the body froze with legs
animating until UpdatePosition snap-teleported it — sometimes outside
the visible window (disappearing) — and constant-velocity drift along
the old heading between snaps produced jitter on every UP correction.

The 882a07c stabilizer was deliberately conservative because the state
WAS incomplete. Completing the data plumbing makes its restriction
moot: with the full MoveTo payload captured, the body solver has every
field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads.

Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while
chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204
all show different cell/xyz floats). Those are heading updates we'd
been throwing away. With the full payload retained, the per-tick driver
steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s
turn rate above tolerance) and lets apply_current_movement fill in
Velocity from the existing RunForward cycle — no new motion path,
just the right heading.

Scope is the minimum viable subset: target re-tracking, sticky/StickTo,
fail-distance progress detector, and sphere-cylinder distance are
server-side concerns we don't need (server's emit cadence handles all
of them). MoveToObject_Internal target-guid resolution is also skipped
— Origin is refreshed each packet, so the effective target tracks the
real entity even without a guid lookup.

Cross-references:
- docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager
  + MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command
  (0x00527be0). 18,366 named PDB symbols make this the primary oracle.
- references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs
  — port aid; flagged divergences (WalkRunThreshold default, set_heading
  snap, inRange one-shot) called out in the new pseudocode doc.
- docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode +
  ACE divergence flags + out-of-scope list per CLAUDE.md mandatory
  workflow (decompile → cross-reference → pseudocode → port).

Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid
retention; driver arrival, in-tolerance snap, beyond-tolerance step,
behind-target shortest-path turn, arrival preserves orientation,
Origin→world landblock-grid arithmetic).

Pending visual sign-off — handoff stabilizer 882a07c was the last
commit the user tested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:49:22 +02:00
Erik
9812965183 fix(anim): Phase L.1c match MoveTo run speed
Retail MovementManager::PerformMovement (0x00524440) reads MoveTo speed and runRate from the packet, MovementParameters::UnPackNet (0x0052AC50) defines the layout, and CMotionInterp::apply_run_to_command (0x00527BE0) multiplies RunForward by runRate. Parse those fields for UpdateMotion/CreateObject, seed server-controlled MoveTo locomotion with the retail speed multiplier, and avoid overriding active monster MoveTo with sparse UpdatePosition-derived velocity.
2026-04-28 20:58:22 +02:00
Erik
4dd8d4b46e fix(anim): Phase L.1c seed move-to locomotion
Retail MoveToManager::BeginMoveForward calls MovementParameters::get_command (0x0052AA00) and then _DoMotion/adjust_motion, so a server-controlled MoveTo begins visible forward locomotion before the next UpdatePosition echo. Seed RunForward for MoveTo packets that omit ForwardCommand, while preserving active locomotion and letting position velocity refine walk/run/stop.
2026-04-28 19:48:12 +02:00
Erik
7656fe0970 fix(anim): Phase L.1c animate server-controlled chase 2026-04-28 19:38:52 +02:00
Erik
646246ba84 feat(anim): Phase L.1c select combat maneuvers 2026-04-28 11:44:17 +02:00