Commit graph

81 commits

Author SHA1 Message Date
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
Erik
460f95cb42 fix(anim): Phase L.1b route motion commands 2026-04-28 10:46:22 +02:00
Erik
b86e77e837 fix(anim): instant-engage Falling cycle on jump start (skip transition link)
User reported transition from running to jumping looked
slow -- the character stood still for ~100 ms at the start
of the jump before the legs folded into Falling.

Root cause: AnimationSequencer.SetCycle resolves a
transition link (e.g. RunForward -> Falling) from the
motion table and enqueues those non-looping link frames
BEFORE the Falling cycle. The link is the "stop running,
prepare to fall" anim -- a few frames of standing-style
pose. While it drained, the character looked frozen.

Fix: SetCycle gains a skipTransitionLink parameter. When
true, the GetLink call is bypassed AND the entire queue is
cleared (so any in-flight non-cyclic frames from a
previous transition don't continue draining). Only the
target cycle gets enqueued, cursor goes straight to its
start.

Both call sites pass true for Falling:
  - OnLiveVectorUpdated (remote-jump VectorUpdate handler)
  - UpdatePlayerAnimation (local airborne path) when
    animCommand == Falling. Other transitions
    (Walk -> Run, Run -> Ready, etc.) keep the link --
    smooth transitions stay smooth, only the jump start
    is hard-cut.

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:16:59 +02:00
Erik
5145938d06 fix(physics): jump arc was zero — stop pre-seeding ContactPlane while airborne
Live diagnostic (extent=1.000, vz=9.09 — formula peak 4.21m) showed
the body's Velocity.Z stayed at ~9 m/s but Position.Z never
advanced past 66.000 even after 575 frames airborne. The collision
resolver was snapping the player back to ground every step.

Root cause: PhysicsEngine.ResolveWithTransition unconditionally
pre-seeded the Transition's CollisionInfo from body.ContactPlane
before each resolve (a slope-walking continuity hack). Once
airborne, that pre-seed makes Transition.CollisionInfo's
ContactPlaneValid stay true. Then in AdjustOffset's "Have a contact
plane" path, when collisionAngle > 0 (offset moving AWAY from the
plane = jumping up), the code calls Plane::snap_to_plane on the
offset which ZEROES the Z component for flat ground (Normal.Z=1,
plane.D=0 → snap_to_plane sets vec.z = 0). The horizontal X/Y
parts of the offset survived; vertical Z was destroyed every step.
Position.Z only ever got the gravity drift back down, so the
"jump" was literally a sub-frame upward blip followed by 575
frames of stuck-at-ground while gravity ate vz.

Retail's CTransition::init at retail address 0x509dd0
(named-retail line 271954) explicitly sets
contact_plane_valid = 0 at the start of every transition resolve.
ValidateWalkable then re-establishes it during the sweep when
the foot sphere bottom is within EPSILON of the terrain plane —
so for grounded motion the plane is set fresh per frame, and for
airborne motion no plane interferes.

Fix: only seed the contact plane when isOnGround is true.
Airborne resolves now start with no plane, so AdjustOffset
preserves the upward Z and the integrator's positional update
actually lands. Slope-walking continuity is preserved because
the seed still fires whenever the body is grounded.

Diagnostic logging stripped after the fix.

Tests stay 1222 green. Live verification pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:17:13 +02:00
Erik
32583cdfe4 fix(anim): walk-backward leg-twitch + jump-too-low — port ACE negative-speed link path + faster charge
Two animation/movement issues from live verification:

1. Walk-backward leg twitches forward two times before the cycle
   reverses (X key glitch).

   Root cause: AnimationSequencer.GetLink only implemented the
   forward-direction lookup path. ACE's MotionTable.get_link
   (MotionTable.cs:395-426) takes BOTH the substate and the new
   motion's speeds, and switches lookup branches when EITHER speed
   is negative:
     * Forward path: Links[(style<<16) | substate][motion]
     * Reversed path (any negative speed): Links[(style<<16) |
                                            motion][substate]
   For Ready → WalkBackward we adjust_motion to WalkForward at
   speed -0.65 (negative). Our previous code looked up
   Links[Ready][WalkForward] — the "start walking forward"
   transition. Played in reverse, the cursor stranded at the
   wrong cycle frame and produced the user-visible "left leg
   twitches forward two times" before the cycle stabilized.
   With the reversed key Links[WalkForward][Ready] (the "stop
   walking → ready" anim) played at the cycle's negative speed,
   the link smoothly transitions Ready → start-of-cycle, then
   the cycle reverses cleanly.

   GetLink signature changed from (style, fromMotion, toMotion)
   to (style, substate, substateSpeed, motion, speed). Both
   call sites updated: SetCycle passes CurrentSpeedMod +
   adjustedSpeed; the Action-overlay path passes 1f, 1f
   (action overlays are always forward).

2. Jump too low.

   Two changes after deep investigation in named-retail decomp:

   a) Charge rate sped up from 1.0/s → 2.0/s. Retail's PowerBar
      charge constant is illegible in the named decomp (the
      divisor was clobbered in GetPowerBarLevel's FPU stack
      reordering at 0x0056ade0). 2.0/s (full charge in 0.5s)
      matches retail muscle memory better — a tap gives a
      noticeable hop, half-hold a meaningful jump, full-hold
      the maximum.

   b) Default jumpSkill bumped 200 → 300. Retail formula:
        height = (skill / (skill + 1300)) × 22.2 + 0.05
      At extent=1.0:
        skill=200 → 3.01m max (felt too low)
        skill=300 → 4.21m max (closer to retail mid-tier "I
                               can clear that fence" hop)
      Override via ACDREAM_JUMP_SKILL env var.

      Long-term fix is issue #7 — parsing PlayerDescription's
      skill block to apply the server's authoritative skill
      values. Until then, this default is the right baseline.

      (Velocity formula sqrt(height × 19.6) is unchanged and
      matches retail byte-for-byte; we only changed how much
      extent-feeding skill we default to.)

Tests stay 1222 green. The walk-backward fix has no new test
because GetLink is private; the cycle-transition behavior
will be exercised live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:46:10 +02:00
Erik
f562215e6c fix(physics): water depth submersion + sphere-safety-push steep-slope correction
Two coupled physics fixes that together resolve "+Acdream walks on top of
water instead of submerged" and "brief Falling animation when running up
steep hills".

## 1. Water depth = physics adjustment, not rendering

Retail has NO separate water surface mesh. Characters visually submerge
in water because ValidateWalkable adds `waterDepth` to its signed-distance
check (ACE ObjectInfo.cs:124), letting the character's feet sit below the
terrain plane by that amount before the push-up fires. Rendered character
below rendered terrain = looks submerged.

Our ValidateWalkable didn't carry a waterDepth, so feet were always
snapped exactly to the plane. Water cells looked like walking on water.

Added:
 - TerrainSurface now carries per-vertex water flags (bits 2-6 of
   TerrainInfo → SurfChar lookup) and per-cell classification.
 - TerrainSurface.SampleWaterDepth(localX, localY) returns 0.0 (dry),
   0.45 (partial-water near water corner), 0.9 (entirely water). Deviates
   from retail's 0.1 fallback for "dry corner of partial-water cell" —
   that 0.1 destabilizes the "feet exactly on plane" contact-touch check
   in ValidateWalkable (dist > EPSILON, SetContactPlane skipped,
   ValidateTransition clears OnWalkable, gravity applies, character
   micro-falls each frame).
 - PhysicsEngine.SampleWaterDepth is the world-space wrapper.
 - FindEnvCollisions samples the per-point depth and forwards it.
 - ValidateWalkable adds +waterDepth to the signed-distance check (this
   is the ACE-line-124 port).

GameWindow.ApplyLoadedTerrain extracts the low byte of each TerrainInfo
ushort and passes it to the TerrainSurface ctor so classification works.

## 2. AdjustOffset safety-push threshold on sloped planes

The LocalSphere is positioned at `(0, 0, radius)` — center along world
+Z from the character root. On a tilted plane the sphere center's
perpendicular distance to that plane is `radius * Normal.Z`, NOT
`radius`. The original threshold `dist < radius - EPS` therefore fires
spuriously on every slope and the follow-up push-up lifts feet by
`radius * (sec θ - 1)` — 7 cm at 30°, 20 cm at 45°, 48 cm at 60°.

The steep-slope lift is large enough to break ValidateWalkable's
contact-touch check, ValidateTransition then clears OnWalkable,
calc_acceleration applies gravity, and the character flickers into the
Falling animation for ~0.3s while running uphill. User-observed on steep
hills after today's water-depth work made the artifact visible (before
that, general hover masked it).

Fix: the threshold is `radius * Normal.Z` (the natural resting distance
of a Z-axis sphere on the plane). The push fires only when feet are
actually penetrating below natural resting, not on any sloped plane.
ACE's Transition.cs AdjustOffset has the original threshold but the bug
is invisible server-side.

All 717 tests green. Water submersion + steep-slope running both
user-visually verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:56:46 +02:00
Erik
40f120617d feat(physics): use sloped terrain plane in FindEnvCollisions
Our previous FindEnvCollisions built a FLAT contact plane (Normal = +Z)
at the sampled terrain Z, discarding the triangle's actual slope.
Retail uses the real terrain polygon's plane (ACE Landblock.cs:125-137
find_terrain_poly → walkable.Plane) which IS sloped.

Without a true slope normal, AdjustOffset's projection of horizontal
velocity onto the plane produces no slope-aligned Z component — fine
for step-subdivision on flat ground, visibly wrong whenever the contact
plane is carried across frames (via PhysicsBody.ContactPlane persistence
from commit 93cbabb): the projection is a no-op and movement is purely
kinematic. With the real slope normal, projected motion correctly
follows the slope.

Not a user-visible bug fix by itself (DIAG LocalZ shows delta≈0 for the
local player everywhere; the "looks too high in water" issue the user
reported is actually a missing water-rendering feature, not a physics
bug). Landing it anyway because it matches retail behavior and removes
the "flat-plane-is-fine" assumption that would bite on any future
contact-plane-dependent code.

Additions:
 - TerrainSurface.SampleSurface(localX, localY) → (Z, Normal), deriving
   the plane normal analytically from the triangle's height gradient.
   Matches the same triangle SampleZ already interpolates through.
 - PhysicsEngine.SampleTerrainPlane(worldX, worldY) → System.Numerics.Plane,
   the wrapper that bridges terrain space to transition space.
 - TransitionTypes.FindEnvCollisions uses SampleTerrainPlane instead of
   synthesizing a flat plane from SampleTerrainZ.

All 717 tests green. Flat-plane case is unchanged (Normal.Z = 1 when
the triangle is level, identical to the old plane).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:42:20 +02:00
Erik
93cbabbc87 fix(physics): full retail per-frame chain for remote motion + persist ContactPlane across frames
Two linked issues both rooted in skipping parts of the retail physics chain.

## 1. Remote staircase on slopes — Euler never integrated between UPs

TickAnimations called rm.Body.update_object(now) for remote integration, but
PhysicsBody.update_object gates on MinQuantum = 1/30s (retail FUN_00515020
early-return). At our 60fps render tick (~16 ms), deltaTime < MinQuantum on
almost every frame → early return AND LastUpdateTime never advances → position
effectively never integrates. Remote Position changed only on UP hard-snap,
producing visible teleport strides uphill (the "staircase" the user reported).

Fix: call UpdatePhysicsInternal(dt) directly for the remote tick — the same
pattern PlayerMovementController.cs:358 uses for the local player. Wire
ResolveWithTransition in afterwards so the remote's Euler-advanced position
gets swept through the same retail collision chain (find_env_collisions +
find_obj_collisions + step_down + 6-path BSP dispatcher) that the local
player already goes through.

New field RemoteMotion.CellId tracks the remote's cell across frames; set
from UpdatePosition.p.LandblockId and updated from transition output.

## 2. Local player floating on downhill slopes — ContactPlane not persisted

Running a character down a slope faster than ~0.5 m/s vertical: per-frame
Euler moves feet horizontally (no Z component since velocity is world-XY).
After Euler, feet are above the new-XY terrain. ValidateWalkable takes the
"above surface" branch without setting a contact plane, DoStepDown probes
~4 cm down (the retail StepDownHeight default), fails to find the surface
8-10 cm below, and the character stays at the old Z. Over a sustained
descent this accumulates into a visible hover.

Retail's PhysicsObj carries ContactPlane + ContactPlaneCellID as persistent
fields (ACE PhysicsObj.cs:2598-2604 get_object_info → InitContactPlane).
Each transition call seeds CollisionInfo.ContactPlane from the previous
frame's plane. That seed is what lets AdjustOffset project horizontal
velocity onto the slope surface — so the Euler offset acquires a Z
component matching the slope and the sphere tracks terrain without needing
step-down to do the catch-up every frame.

Fix: add PhysicsBody.ContactPlane* fields mirroring PhysicsObj's. Extend
ResolveWithTransition with an optional `body` parameter; when provided, seed
the transition's CollisionInfo from body.ContactPlane at the start, copy
back (preferring current, falling back to LastKnown) at the end. Both local
(PlayerMovementController) and remote (TickAnimations) pass their body.

Verified live: DIAG samples showed pre/post/resolved Z all exactly equal
before the MinQuantum bypass (Euler frozen). After bypass, deltas dropped
to floating-point noise on slopes for remotes. Local hover on downhill
resolved in separate visual pass.

All 717 tests green. No API breaks (ResolveWithTransition's body param is
optional, backwards-compatible).

Cross-refs:
 - decompile: FUN_00515020 update_object, FUN_005111D0 UpdatePhysicsInternal,
   FUN_005148A0 transition init
 - ACE: PhysicsObj.cs:2586-2621 get_object_info, Transition.cs:613-620 InitContactPlane

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:55:59 +02:00
Erik
56975f8919 fix(terrain): align per-cell triangle geometry with ACE's ConstructPolygons convention
Our LandblockMesh, terrain.vert corner tables, and TerrainSurface.SampleZ
used the OPPOSITE diagonal for each CellSplitDirection enum value from
what ACE (and the decompiled retail client at FUN_00532a50) picks for the
same sign bit. Same formula, same sign-bit mapping, inverted geometry.

Symptom: remote players rendered at server-broadcast Z hovered or clipped
by up to ~1m on sloped cells. Flat cells masked the bug because all four
corner heights were equal so any triangle pair returned the same Z. Live
diagnostic confirmed +0.79m hover on cell (7,5) at lb(AA,B4) — a ~20°
slope — while flat neighbors agreed to floating-point noise.

Three coordinated edits so CPU mesh + GPU corner lookup + CPU sampler all
agree on the retail geometry:
 - LandblockMesh: SWtoNE branch now emits {BL,BR,TR}+{BL,TR,TL} (y=x cut),
   SEtoNW emits {BL,BR,TL}+{BR,TR,TL} (x+y=1 cut).
 - terrain.vert: corner-index tables updated to match.
 - TerrainSurface.SampleZ: swapped the two branches' interpolation.

After the fix, 19 live DIAG samples across flat + two slope transitions
all land within 0.01m of server Z. Staircase pattern during remote motion
on slopes is a separate bug (no per-frame collision resolution) and will
be addressed via the transition/FindValidPosition port.

Cross-verified against: ACE LandblockStruct.ConstructPolygons lines 221-
244, decompiled retail FUN_00532a50 (chunk_00530000.c:2235), ClientReference
IsSWtoNECut (tests/AcDream.Core.Tests/Terrain/ClientReference.cs).

Updated test SplitDirection_TerrainSurface_AgreesWith_TerrainBlending
with corrected expectations (Z values swap between the two branches).
All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:20:59 +02:00
Erik
340dabbc72 feat(anim): full retail remote-entity motion port — walk/run/strafe/turn/stop
Ports the retail client's client-side remote-entity motion pipeline
verbatim per the decompile research. Every remote now runs its own
PhysicsBody + MotionInterpreter + AnimationSequencer stack — retail has
no special "interpolator" for remotes, it runs the full motion state
machine on every entity. Now we do too.

## What changed

### Parser fixes (CreateObject, UpdateMotion)
Wire flag bits for InterpretedMotionState (per ACE MovementStateFlag enum):
  CurrentStyle=0x01, ForwardCommand=0x02, ForwardSpeed=0x04,
  SideStepCommand=0x08, SideStepSpeed=0x10, TurnCommand=0x20, TurnSpeed=0x40

Previously we only extracted CurrentStyle + ForwardCommand + ForwardSpeed
and SKIPPED the side/turn fields entirely. Result: we had zero rotation-
or strafe-intent data from the server — impossible to render turn or
sidestep animations. Now ServerMotionState carries all 7 fields and the
parser reads the bytes in ACE's write order (style, fwd, side, turn, then
fwdSpd, sideSpd, turnSpd).

### RemoteMotion (new per-remote struct in GameWindow)
Each remote gets its own PhysicsBody + MotionInterpreter + observed
angular velocity. Replaces the earlier shortcut RemoteInterpolator
(deleted — retail has no such thing).

On UpdateMotion:
  - ForwardCommand flag absent → stop signal (reset to Ready) per
    retail FUN_0051F260 bulk-copy semantics (absent = Invalid = default).
  - Forward + sidestep + turn each route through DoInterpretedMotion,
    exactly as retail FUN_00528F70 does.
  - Animation cycle selection: forward wins if active, else sidestep,
    else turn, else Ready. Matches the user's observation that retail
    plays turn animation when only turning.
  - Turn command seeds ObservedOmega = π/2 × turnSpeed (from Humanoid
    MotionData.Omega.Z ≈ π/2 per decompile).
  - Turn absent → ObservedOmega = 0 (stops rotation immediately).

On UpdatePosition:
  - Hard-snap Body.Position + Body.Orientation per retail FUN_00514b90
    set_frame (direct assignment, no slerp — retail does not soft-snap).
  - HasVelocity + |v| < 0.2 → StopCompletely + SetCycle(Ready).
  - ForwardSpeed=0 on wire is a VALID stop signal (ACE sends this when
    alt releases W); previously we defaulted to 1.0, causing the "slow
    walk that never stops" symptom.

Per-tick:
  - apply_current_movement → Body.Velocity via get_state_velocity
    (retail FUN_00528960: RunAnimSpeed × ForwardSpeed in body-local,
    rotated by orientation).
  - Manual omega integration: Orientation *= quat(ObservedOmega × dt).
    Bypasses PhysicsBody.update_object's MinQuantum=1/30s gate that
    was eating every-other-tick rotation updates at our 60fps render
    rate — the cause of the persistent "rotation snaps every UP" bug.
  - update_object still called for position integration and the motion
    subsystem it drives.

### AnimationSequencer synthesis extension
Added omega synthesis for TurnRight/TurnLeft cycles (same pattern as
the earlier velocity synthesis): when the Humanoid dat leaves HasOmega
clear, SetCycle synthesizes CurrentOmega = ±π/2 × speedMod on Z so
dead-reckoning and stop detection can read a non-zero omega for turn
cycles.

### Stop-detection heuristic removed
No more 300ms/2000ms/5000ms idle timers. Retail's stop signal is
explicit (UpdateMotion with ForwardCommand flag absent → Ready); we
handle it directly. Client-side timers were a source of flicker during
normal running.

## Confirmed working
- Walking (matches retail speed + leg cadence)
- Running (matches retail speed + leg cadence)
- Strafing (body moves sideways + strafe animation plays)
- Turning while stationary (body rotates smoothly + turn animation plays)
- Turning while running (body rotates + leg anim continues)
- Stopping (instant stop, no slow-walk tail)

All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:26:23 +02:00
Erik
00c8a4feb5 fix(anim): stop ACE echo from clobbering player's speedMod; synthesize sequencer velocity
Two related bugs in the motion/animation pipeline:

1. Player's local animation was getting reset to speedMod=1.0 every ~100ms.
   ACE's BroadcastMovement echoes the player's own motion back via
   UpdateMotion. When ACE's ForwardSpeed == 1.0, the ForwardSpeed flag is
   omitted (InterpretedMotionState.BuildMovementFlags), so our wire parser
   returns null and we default to speedMod=1.0 — clobbering the
   locally-authoritative 2.375 × runRate that UpdatePlayerAnimation just
   set. Legs would crank up to full cadence for one frame then get slammed
   back to walking rate.

   Fix: for the player's own guid, skip the wire-echo SetCycle entirely.
   UpdatePlayerAnimation is the authoritative driver for the local
   player's animation; the server echo is only useful for observers of
   other characters. User-confirmed: legs now hold their full cadence.

2. Remote entities teleported between UpdatePositions because the
   sequencer's CurrentVelocity was always zero (Humanoid dat ships every
   locomotion MotionData with Flags=0x00, so EnqueueMotionData leaves
   CurrentVelocity at Vector3.Zero). Dead-reckoning's Priority 1
   (sequencer velocity) never triggered, falling through to EMA which
   has bootstrap lag + gets polluted by teleport-class server snaps.

   Fix: synthesize CurrentVelocity in SetCycle from the retail locomotion
   constants (WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25)
   × speedMod, matching the decompiled get_state_velocity (FUN_00528960)
   which uses these same constants directly instead of MotionData.Velocity.
   The dat's HasVelocity field is reserved for non-locomotion motions
   (kick-off velocities, flying creatures, etc).

   Diag confirmed synthesis fires and DR picks it up with src=seq and
   correct magnitude. More visual polish may still be needed for the
   "lagging remote" symptom — see follow-up.

Also adds `PlayerMovementController.BodyVelocity` utility getter for HUD/
debug use, and `ACDREAM_ANIM_SPEED_SCALE` env var as a tunable knob for
visual pacing overrides.

All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:22:35 +02:00
Erik
795d9c8a88 fix(anim): physics velocity now sourced from MotionData — option B / r03 §1.3
The decompiled get_state_velocity (FUN_00528960) literally computes
`RunAnimSpeed * ForwardSpeed` — a 4.0 × runRate world velocity. That
matches retail only when the character's MotionTable happens to bake
MotionData.Velocity.Y = 4.0 on RunForward (true for Humanoid, not
necessarily for other creatures or swapped weapon-style cycles).

When MotionData.Velocity ≠ RunAnimSpeed, the body's world velocity
drifts away from the animation's baked-in root-motion velocity, and
you see the classic "legs cycle too slowly for how fast the body is
sliding" visual bug. User reports ~30% discrepancy ("running animation
is too slow"), consistent with Humanoid RunForward's actual dat
Velocity being ~3.0 rather than the 4.0 constant.

The fix per r03 §1.3: physics body velocity = MotionData.Velocity ×
speedMod. That's exactly what AnimationSequencer.CurrentVelocity
already exposes. Route it into MotionInterpreter via an opt-in
Func<Vector3> accessor. When wired, get_state_velocity uses the
sequencer's cycle velocity as the primary forward-axis drive; when
unwired (tests, physics bodies without a sequencer), falls back to
the decompiled constant path — byte-compatible with retail on the
shapes where it actually matters.

The RunAnimSpeed × rate max-speed clamp at the bottom of
FUN_00528960 stays intact — Option B only replaces the *drive*, not
the clamp. 20 m/s phantom MotionData can't teleport the player.

Wiring: GameWindow attaches `playerAE.Sequencer.CurrentVelocity` to
`_playerController` on Tab-player-mode entry. The sequencer is always
built before the player enters chase mode, so timing is safe.

Sidestep continues to use SidestepAnimSpeed — the sequencer only
tracks the current forward cycle, so strafe is a separate axis.

6 new MotionInterpreterTests verify: accessor overrides constant path,
zero Y falls back to constant (link transitions), clamp still applies,
Ready state doesn't leak accessor value, sidestep axis is untouched.

All 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:06:08 +02:00
Erik
24974cfbb9 refactor(anim): sequence-wide velocity/omega matching retail Sequence
Before: CurrentVelocity was a pass-through of the current AnimNode's
Velocity. So during a stance transition, while the link animation
played (with no velocity of its own), CurrentVelocity returned (0,0,0)
and remote dead-reckoning briefly stopped advancing the entity. Visible
as a hitch at every idle → walk or walk → run transition.

Retail's model (ACE Sequence.cs L16-L17, L127-L130): Velocity and Omega
are Sequence-wide fields updated by MotionTable.add_motion's
Sequence.SetVelocity call (MotionTable.cs L358-L370). Every time a new
MotionData is appended, the sequence velocity is REPLACED by that data's
velocity × speedMod. In SetCycle's rebuild path the order is:
  1. clear_physics      → zero
  2. add_motion(link)   → velocity = link's (typically 0)
  3. add_motion(cycle)  → velocity = cycle's (the real walk/run velocity)

After step 3, Sequence.Velocity is the CYCLE's velocity even though
CurrAnim is the link node. So dead-reckoning reads the cycle's velocity
from frame zero of the transition — no stutter.

This commit:

- Converts AnimationSequencer.CurrentVelocity / CurrentOmega from
  per-node computed properties to sequence-wide private-set properties.
- Adds ClearPhysics() helper (mirrors Sequence.clear_physics).
- EnqueueMotionData now updates the sequence velocity/omega (matching
  add_motion's SetVelocity semantics). Only replaces when the
  MotionData's HasVelocity/HasOmega flags are set — zero-HasVelocity
  modifiers don't zero the running cycle, matching retail.
- SetCycle's rebuild path calls ClearPhysics before the new add_motion
  chain (matches MotionTable.cs L100-L101, L152-L153).
- MultiplyCyclicFramerate scales the sequence-wide velocity/omega
  instead of per-node fields — algebraically equivalent to retail's
  subtract_motion(old) + combine_motion(new) pair in change_cycle_speed.

New test: CurrentVelocity_PersistsThroughLinkTransition — verifies that
after SetCycle enqueues [link][cycle], CurrentVelocity is the cycle's
velocity even during the link frames. Catches the old bug directly.

All 659 tests pass (was 658).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:41:21 +02:00
Erik
3f41872d88 feat(anim): route Commands[] list — full NPC/monster motion support
UpdateMotion's InterpretedMotionState payload includes not just
ForwardCommand but a whole Commands[] list of MotionItem entries — each
carrying an Action (attack, portal, skill use), Modifier (jump,
stop-turn), or ChatEmote (Wave, BowDeep, Laugh) that should overlay the
current cycle. The old parser stopped reading after ForwardSpeed, so
emotes/attacks/deaths never reached the sequencer and NPCs just sat in
their idle cycle.

Three parts:

1. New MotionItem wire record in ServerMotionState — carries Command
   (u16), PackedSequence (u16 with IsAutonomous bit + 15-bit stamp),
   and Speed (f32). Mirrors ACE Network/Motion/MotionItem.cs.

2. Both UpdateMotion.TryParse and CreateObject.TryParseMovementData
   now read the full InterpretedMotionState: all 7 flag fields
   (CurrentStyle, ForwardCommand, SidestepCommand, TurnCommand,
   ForwardSpeed, SidestepSpeed, TurnSpeed) plus the numCommands ×
   MotionItem tail. The packed u32 encodes flags in low 7 bits and
   command count in bits 7+ (see ACE InterpretedMotionState.cs:131).

3. New MotionCommandResolver — reconstructs the 32-bit MotionCommand
   class byte from a 16-bit wire value via a reflection-built lookup
   of DatReaderWriter.Enums.MotionCommand. Server serializes as u16
   (ACE InterpretedMotionState.cs:139) and we need the class to route:
     - 0x10xxxxxx Action / 0x20xxxxxx Modifier / 0x12,0x13 ChatEmote →
       PlayAction (resolves from Modifiers or Links dict, overlays on
       current cycle)
     - 0x40xxxxxx SubState → SetCycle (cycle change)

4. OnLiveMotionUpdated in GameWindow dispatches each command:
     - SubState class (0x40xxx) → SetCycle (treated same as
       ForwardCommand)
     - Action/Modifier/ChatEmote → PlayAction — the link animation
       plays once then drops back to the current cycle naturally
       (matches retail's action-queue pattern in CMotionInterp
       DoInterpretedMotion, decompile FUN_00528F70).

Result: NPCs now animate attacks, waves, bows, death throes, and other
one-shots that ACE broadcasts via the Commands list rather than the
primary ForwardCommand field. Combined with the dead-reckoning + speed-
scaling from the prior commits, remote characters look visually correct
during the full motion spectrum (idle → walk → run → attack → death).

Tests: 2 new UpdateMotion wire-format tests (ForwardSpeed parse, full
Wave command list parse) + 19 new MotionCommandResolver reconstruction
tests covering SubState, Action, and ChatEmote classes. 654 tests green
(was 633).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:34:18 +02:00
Erik
afafefd71f feat(anim): MultiplyCyclicFramerate — retail mid-cycle speed change
When the server broadcasts a mid-run UpdateMotion with a different
ForwardSpeed (e.g. the player's RunRate changes due to stamina / skill
update), acdream must NOT restart the cycle — that would reset the
footstep cursor and look like a visible twitch. Retail handles this via
Sequence.multiply_cyclic_animation_framerate (ACE
references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs L277-L287),
which walks the cyclic tail of the queue and scales each node's
framerate by newSpeed / oldSpeed. MotionTable.change_cycle_speed
(MotionTable.cs L372-L379) is the caller from the same-motion path in
GetObjectSequence (L132-L139).

This commit:

1. Adds AnimNode.MultiplyFramerate(factor) — scales a single node's
   framerate. Retail also swapped StartFrame↔EndFrame for negative
   factors; acdream keeps StartFrame ≤ EndFrame as an invariant and
   encodes direction via Framerate sign (see existing comment in
   LoadAnimNode), so we only scale. Valid because callers only ever
   pass positive factors from UpdateMotion ForwardSpeed.

2. Adds AnimationSequencer.MultiplyCyclicFramerate(factor) — walks
   _firstCyclic through the tail and calls node.MultiplyFramerate(factor).
   Also scales each node's Velocity and Omega by the same factor so
   CurrentVelocity / CurrentOmega stay aligned with playback — matches
   ACE's subtract_motion + combine_motion pair in change_cycle_speed.

3. Adds AnimationSequencer.CurrentSpeedMod public property — starts at
   1.0, updated by SetCycle on both restart and mid-cycle rescale.

4. Adds a speed-change fast-path to SetCycle: when the (style, motion)
   pair matches the current cycle and signs agree,
   MultiplyCyclicFramerate(newSpeed/oldSpeed) is called instead of
   rebuilding the queue — the cursor stays where it is and the animation
   continues at the new rate.

5. Wires InterpretedMotionState.ForwardSpeed from UpdateMotion through
   to SetCycle in OnLiveMotionUpdated. ACE omits the ForwardSpeed flag
   when speed == 1.0 (InterpretedMotionState.cs:101-103), so we default
   missing/zero values to 1.0.

Tests: 4 new sequencer tests covering MultiplyCyclicFramerate,
cursor preservation across speed changes, the same-motion-different-speed
fast-path, and the same-motion-same-speed no-op guard. 632 tests green
(was 628).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:26:55 +02:00
Erik
b04d393329 feat(anim): Phase E.1 hook router + GameWindow wiring
Adds IAnimationHookSink + AnimationHookRouter for fan-out of animation
hooks to downstream subsystems (audio, particles, combat, renderer
mutators). GameWindow.TickAnimations now drains ConsumePendingHooks
every tick and broadcasts each hook via the router with the entity's
world position pre-computed.

The router is a composite sink: register N sinks once at startup, each
sees every hook. Registration is idempotent, unregister works, and a
throwing sink no longer poisons dispatch (each OnHook call is wrapped in
try/catch so one bad subsystem can't halt the whole animation tick).

A NullAnimationHookSink is provided for headless tests / offline mode.

6 router tests verify: single/multi sink fan-out, idempotent register,
unregister, throwing-sink isolation, null-sink no-op.

Total: 376 Core tests + 109 Core.Net = 485 (up from 479).

This closes Phase E.1 plumbing; E.2 (audio) and E.3 (particles) will
each register a concrete sink that translates their hook types into
real-world effects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:30:23 +02:00
Erik
4db0b2f16c feat(anim): Phase E.1 motion hooks + PosFrames + velocity/omega surfacing
AnimationSequencer now walks every integer frame boundary crossed in a
tick (ACE Sequence.update_internal pattern), dispatching AnimationHook
objects whose Direction matches the playback direction (Forward or
Backward) or is Both. Mirrors ACE's Sequence.execute_hooks exactly.

New public API:
- ConsumePendingHooks() drains all hooks fired since last call, including
  AnimationDone sentinel on link-node drain (emote/attack completion).
- ConsumeRootMotionDelta() drains accumulated PosFrames root motion;
  AFrame.Combine (forward) / AFrame.Subtract (backward) applied per
  crossed frame to match retail.
- CurrentVelocity / CurrentOmega expose the active MotionData's velocity
  and omega (scaled by speedMod at enqueue), letting downstream physics
  integrate the animation-driven motion.

All 27 AnimationHookType variants (SoundHook, AttackHook,
CreateParticleHook, ReplaceObjectHook, DefaultScriptHook, SetOmegaHook,
TransparentHook, ScaleHook, SetLightHook, etc.) now flow through the
hook queue. Consumers in E.2/E.3 (audio + particles) will route them to
the right subsystems.

9 new tests cover: forward-hook crossing fires exactly once, Both-direction
fires in either direction, Forward-only suppressed on reverse playback,
Backward fires on reverse, PosFrames accumulation + drain, Velocity
exposure + speedMod scaling, AnimationDone fires on link drain.

Build green; 470 tests → 479 (361 Core + 9 new E.1 hook tests + 109 Net).

Ref: docs/research/deepdives/r03-motion-animation.md §5 (hooks), §7.1-7.2
(PosFrames), §7.3 (negative framerate).
Ref: ACE Sequence.cs:262 (execute_hooks), Sequence.cs:351-443
(update_internal per-frame crossing walk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:28:15 +02:00
Erik
6bce9b8019 fix(anim): jump animation via Falling SubState + kept PlayAction infra for emotes
The actual retail behavior for jump animations is a plain SubState swap —
NOT an Action overlay as I'd initially guessed. MotionCommand.Falling
(0x40000015) is a SubState cycle whose motion-table entries handle the
whole jump lifecycle:

  - Links[(stance, RunForward)][Falling]     = leap-into-air link
  - Cycles[(stance, Falling)]                = airborne cycle (loops)
  - Links[(stance, Falling)][Ready/...]      = landing link back to normal

Empirical verification from the diagnostic dump:
    Links[0x003D0007] has 3 inner entries:
        inner key: 0x41000003 (Ready)
        inner key: 0x45000005 (WalkForward)
        inner key: 0x40000015 (Falling) ← jackpot

SetCycle() already handles SubState + Links + Cycles resolution correctly,
so the whole fix is three lines:

    if (!result.IsOnGround)
        animCommand = MotionCommand.Falling;

What's in this commit:
- Added MotionCommand.Falling (0x40000015) constant + comments explaining
  the retail jump-is-a-SubState flow
- GameWindow.UpdatePlayerAnimation swaps to Falling when airborne (the
  cleanest possible implementation — motion table does all the work)
- Kept AnimationSequencer.PlayAction infrastructure (ported via Links
  fallback + Modifiers fallback). Not needed for jump, but perfectly
  valid for emotes like /wave, /bow (found in the same Links dict as
  inner keys 0x13000080-0x13000083) and eventual combat attacks
- Kept MotionCommand.Jump / Jumpup / FallDown constants (unused for now
  but useful reference if non-humanoid motion tables use them)
- Removed all diagnostic logging

What was learned (for future motion work):
- Retail's MotionTable.Cycles dict holds SubState loops (Ready, Walk,
  Run, Falling, Crouch, etc.) by (style<<16) | (motion & 0xFFFFFF)
- MotionTable.Links dict holds TRANSITIONS between motions: the OUTER
  key is the (style, fromMotion) combo; the INNER key is the TARGET
  motion. The stored MotionData IS the link animation played during
  the transition. This is what ACE's get_link traverses.
- MotionTable.Modifiers dict holds overlay motions (mask 0x20) — rare
  for humanoids, only 8 TurnRight/SideStepRight stance variants
- Actions (mask 0x10) in retail ALSO go through Links — they're
  transition animations FROM current substate, not overlays. Use
  PlayAction (now correctly routed to Links dict) for them.

Jump animation now works retail-faithfully for running + jumping off.
Standing-jump behavior depends on whether the player's motion table
has a Ready→Falling link; SetCycle's fallback chain should handle it
via the style-level catch-all if the direct link is absent.

470 tests pass. Build clean.
2026-04-18 15:32:52 +02:00
Erik
08ea2c0af8 feat(anim): motion-action-queue infrastructure + retail jump-is-physics-only note
Adds AnimationSequencer.PlayAction as the proper path for Action and
Modifier-class motions (the MotionTable.Modifiers dict, distinct from
Cycles). Action nodes are inserted before the looping cyclic tail so
they drain once and the cycle resumes naturally — leveraging the
sequencer's existing "non-looping head drains, cyclic tail wraps"
queue semantics.

What this does:
- New AnimationSequencer.PlayAction(motionCommand, speedMod=1f):
  - Resolves (style<<16) | (motion&0xFFFFFF) from MotionTable.Modifiers
  - Falls back to (motion&0xFFFFFF) plain key
  - Silent no-op when not found (some motion tables lack these)
  - Inserts AnimNodes before _firstCyclic; re-points the cursor when on
    the cyclic tail so the action plays immediately
- New MotionCommand.Jump (0x2500003B) + MotionCommand.FallDown (0x10000050)
  constants.
- GameWindow.UpdatePlayerAnimation fires PlayAction(Jump) on
  result.JumpExtent.HasValue and PlayAction(FallDown) on JustLanded.

Key research finding: retail does NOT animate jumps.
- ACE Player.HandleActionJump explicitly clears PendingMotions and sets
  IsAnimating=false during a jump (Player.cs:914-915).
- Empirical verification: the player humanoid's MotionTable only has 8
  Modifier entries — all TurnRight/SideStepRight stance variants. No
  Jump (0x2500003B) or FallDown (0x10000050) entries.
- Jump is a physics-only action: the character keeps whatever cycle
  was active (walk/run/idle) while the physics body arcs through the
  air. There is no "raise arms to jump" pose in retail.

PlayAction is still called on jump/land as a safety hatch for creature
Setups that DO carry leap animations in their Modifiers dict (drudge
jumps, monster pounces, etc.). For player humanoids it's a no-op. The
infrastructure is also ready for future emote/combat actions that
legitimately use the Modifiers dict.

470 tests pass, build clean.
2026-04-18 15:12:12 +02:00
Erik
ff325abd7b feat(ui): debug overlay + refined input controls
Adds the first on-screen HUD for the dev client plus today's mouse-control
refinements. Also lands yesterday's scenery-alignment changes that were
left uncommitted in the working tree.

Overlay:
- BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512
  R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks)
- TextRenderer batches 2D quads in screen-space with ortho projection;
  one shader + two draw calls (rect then text) for panel backgrounds
  under glyphs
- DebugOverlay composes info / stats / compass / help panels on top of
  the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events
- DebugLineRenderer and its shaders (carried over from the scenery work)
  are properly committed in this commit

Controls:
- Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to
  adjust the active mode multiplicatively (x1.2)
- Hold RMB to free-orbit the chase camera around the player; release
  stays at the new angle (no snap-back)
- Mouse-wheel zooms chase distance between 2m and 40m
- Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from
  the default neutral angle

Scenery alignment (carried from yesterday's session):
- ShadowObjectRegistry AllEntriesForDebug + Scale field
- SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc +
  set_heading rotation
- BSPQuery dispatchers accept localToWorld so normals/offsets transform
  correctly per part
- TransitionTypes.CylinderCollision rewritten with wall-slide + push-out
- PhysicsDataCache caches visual-mesh AABB for scenery that lacks
  physics Setup bounds
2026-04-17 18:45:38 +02:00
Erik
6b4e7569a3 fix(physics): transform collision normals/offsets from local→world space
The BSP collision detection runs in object-local space, but the
collision response (normals, push offsets) was being applied directly
to world-space SpherePath without rotating back to world space. For
rotated objects (trees, rocks, buildings), this caused the push
direction to be wrong — pushing the player sideways or into the
object instead of away from it.

Added localToWorld quaternion parameter to FindCollisions and all
helper methods (StepSphereDown, CollideWithPt, NegPolyHitDispatch).
All normals and offsets are now transformed via
Vector3.Transform(v, localToWorld) before being applied to SpherePath,
matching ACE's path.LocalSpacePos.LocalToGlobalVec() pattern.

Indoor cell collision uses Quaternion.Identity (cell-local = world).
Object collision passes obj.Rotation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:32:41 +02:00
Erik
874bcc8690 feat(physics): retail-faithful collision system port from ACE
Replace the patched collision system (~60-70% retail) with a faithful
port of ACE's BSPTree/BSPNode/BSPLeaf/Polygon collision pipeline.

BSPQuery.cs completely rewritten (1808 lines):
- Polygon-level: polygon_hits_sphere_precise (retail two-loop test),
  pos_hits_sphere, hits_sphere, walkable_hits_sphere, check_walkable,
  adjust_sphere_to_plane, find_crossed_edge, adjust_to_placement_poly
- BSP traversal: sphere_intersects_poly, find_walkable, hits_walkable,
  sphere_intersects_solid, sphere_intersects_solid_poly
- BSP tree-level: find_collisions (6-path dispatcher), step_sphere_up,
  step_sphere_down, slide_sphere, collide_with_pt, adjust_to_plane,
  placement_insert

PhysicsDataCache.cs: Added ResolvedPolygon type with pre-computed
vertex positions and face planes (matching ACE's Polygon constructor
which calls make_plane() at load time). Populated at cache time to
avoid per-collision-test vertex lookups.

TransitionTypes.cs: FindObjCollisions rewritten to use the retail
per-object FindCollisions 6-path dispatcher instead of the old
"find earliest t, then apply custom response" approach. BSP objects
now go through the same collision paths as indoor cell BSP.

The previous approach was explicitly rejected by the user after ~10
iterations of patches. This port follows the CLAUDE.md mandatory
workflow: decompile first → cross-reference ACE → port faithfully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:18:43 +02:00
Erik
cadc72ed08 feat(physics): complete retail collision — indoor BSP, dual sphere, step-up, swept-sphere, 6-path dispatcher
Indoor CellStruct PhysicsBSP collision for room walls/ceilings.
Dual sphere (body+head) from Setup dimensions.
StepUp attempts before sliding when hitting low obstacles.
FindTimeOfCollision for exact parametric BSP contact time.
Full 6-path BSP dispatcher wired into FindEnvCollisions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:00:52 +02:00
Erik
1f30fbd2f5 fix(physics): increase contact point safety margin
Rewind to t-0.02 instead of exact contact time, plus 2cm normal
push-back. The previous 0.5cm was too small — at high speed the
sub-step could overshoot past the surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:35:06 +02:00
Erik
2ebbfc4864 fix(physics): don't fall back to no-collision resolve on transition fail
When FindTransitionalPosition fails (stuck in corner, too many steps),
use the partially-resolved position instead of falling back to the
simple Resolve which has no object collision. This prevents walking
through objects when the transition can't find a clean path.

The player now stops at corners instead of clipping through.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:58:06 +02:00
Erik
08c4f2764c chore: strip collision debug logging
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:55:18 +02:00
Erik
4d335c74da fix(physics): use horizontal distance for cylinder broad-phase
The broad-phase rejection was using 3D distance for cylinder objects,
which includes the Z offset between player feet and cylinder base.
Trees have their origin at the base (Z=ground) while the player
sphere is at chest height (Z=ground+~2.5m). The 3D distance exceeded
the combined radius, causing the collision test to be skipped entirely.

Fix: use horizontal (XY) distance for cylinder broad-phase since
the vertical extent is checked separately in the cylinder test.
Also increase broad-phase margin from 1m to 2m.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:49:04 +02:00
Erik
efd6a06c7d fix(physics): search adjacent landblocks for collision objects
GetNearbyObjects now searches the player's landblock plus all 8
neighbors. Previously only searched one landblock, missing objects
near landblock boundaries — which includes most trees/rocks since
scenery is placed across the full streaming window.

Also added diagnostic logging (will strip after verification).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:44:50 +02:00
Erik
f2548146b5 fix(physics): push-out when spawned inside objects (stuck prevention)
When collision is detected at t=0 (already overlapping at step start),
push the sphere out along the collision normal by half-radius instead
of trying to slide with zero displacement (which gets stuck).

Returns Adjusted instead of Slid so the transition loop retries
from the pushed-out position.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:34:29 +02:00
Erik
8e1230c53b fix(physics): swept-sphere collision prevents penetration
Restructure FindObjCollisions to compute collision ALONG the movement
path instead of at the final position:

BSP: movement-aware SphereIntersectsPoly with front-face culling
  (dot(movement, normal) < 0). Only detects faces the sphere is
  approaching, matching retail Polygon.pos_hits_sphere.

Cylinder: quadratic ray-cylinder intersection computes parametric
  contact time t. If t < 1.0, sphere is rewound to the contact point.

Both: find the EARLIEST collision (minimum t), rewind sphere to
  contact point + small epsilon along normal, then SlideSphere.

This prevents the "walking into walls" penetration (BUG-005).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:23:10 +02:00
Erik
14b0a6e2b8 feat(physics): CylSphere collision for trees, rocks, NPCs
Most scenery objects (trees, rocks) use CylSphere collision from
their Setup, not PhysicsBSP. Register these in ShadowObjectRegistry
with a Cylinder collision type. FindObjCollisions now handles both:
- BSP: full polygon collision via BSPQuery (buildings, stabs)
- Cylinder: radial + vertical cylinder-sphere test (trees, NPCs)

Diagnostics showed 170 CylSphere entities vs 278 BSP entities in
the Holtburg landblock alone — this roughly doubles collision coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:14:28 +02:00
Erik
2a4aaf4db7 feat(physics): port full BSPTree.find_collisions from retail
Replace simplified BSP overlap test with retail-faithful 6-path
collision dispatcher. Sphere-intersects-poly now uses movement
vector for front-face culling (prevents wall penetration).
All paths: placement/ethereal, checkWalkable, stepDown, collide,
contact+onWalkable, and default (not in contact).

Ported from ACE BSPTree.cs/BSPNode.cs/BSPLeaf.cs/Polygon.cs,
cross-referenced against decompiled chunk_00530000.c.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:07:09 +02:00
Erik
e12d255d2e feat(physics): port full CTransition collision response from pseudocode
Replace simplified push-out with retail-faithful SlideSphere and
AdjustOffset from transition_pseudocode.md. Crease-projection between
collision normal and contact plane produces smooth wall-sliding.
Object collision uses proper rotation transform to object-local space.

SlideSphere (section 6): computes crease direction via cross product
of collision normal and contact plane normal, projects displacement
onto the crease, then applies the correction offset. Handles three
cases: crease exists, parallel same-direction, parallel opposing.

AdjustOffset (section 6): adds safety check to keep sphere above
contact plane by computing signed distance and pushing up along Z
when the sphere dips below.

FindObjCollisions: removes ad-hoc penetration push-out, now calls
SlideSphere after BSP hit detection for proper wall-slide behavior.

Also fixes: ShadowEntry gains Rotation field, tests updated to match
Register signature, unused variables removed from GameWindow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:17:45 +02:00