Commit graph

65 commits

Author SHA1 Message Date
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
Erik
e2f0c8580e feat(physics): cell-based ShadowObject collision
Register static entities into terrain cells during streaming.
Transition system queries nearby objects and runs BSP collision.
Player can no longer walk through trees and buildings.

- ShadowObjectRegistry: 24m×24m cell index, Register/Deregister/
  RemoveLandblock/GetNearbyObjects matching retail AC's approach
- PhysicsEngine: ShadowObjects property + DataCache wiring point;
  RemoveLandblock now also clears shadow objects; TryGetLandblockContext
  helper lets Transition resolve landblock id+offset for a world pos
- Transition.FindObjCollisions: queries registry, broad-phase sphere test,
  narrow-phase BSPQuery.SphereIntersectsPoly in object-local space,
  returns Slid on hit to redirect movement along the surface
- GameWindow.ApplyLoadedTerrainLocked: registers each static entity after
  physics BSP data is cached; selects radius from BSP bounding sphere or
  Setup.Radius; wires PhysicsDataCache into engine on OnLoad
- 16 new ShadowObjectRegistry unit tests, all 361 tests green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:05:09 +02:00
Erik
246713e2cc feat(physics): wire CTransition sphere-sweep into player movement
Replace simple Z-snap PhysicsEngine.Resolve with ResolveWithTransition
that uses the ported CTransition sphere-sweep pipeline. Movement is
subdivided into sphere-radius steps, terrain collision tested at each
step with step-down for ground contact maintenance.

Falls back to simple Resolve if transition fails. Player controller
now passes pre/post integration positions to the transition system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:58:55 +02:00
Erik
31cd5480dc fix(physics): jump apex velocity zeroing bug
SmallVelocity threshold (0.25 m/s) in UpdatePhysicsInternal was zeroing
velocity every frame while airborne at the jump apex. With vel~0.01 m/s
and gravity adding only 0.012/frame, the zeroing won every frame and
the character got stuck at peak height forever.

Fix: only apply small-velocity zeroing when OnWalkable (grounded).
While airborne, gravity must accumulate freely through the zero-crossing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:13:27 +02:00
Erik
157ed9d974 fix(movement): jump works locally (airborne velocity preserved)
Two fixes for jump physics:
- Skip ground-snap when velocity Z > 0 (prevents immediate re-landing
  at high framerates where per-frame Z delta < 0.05 snap threshold)
- Guard apply_current_movement velocity write behind OnWalkable check
  (prevents MotionInterpreter.DoMotion from zeroing jump velocity on
  every frame while airborne)
- Guard PlayerMovementController velocity replacement behind OnWalkable
  (preserves momentum during airborne flight)

Jump works locally but server packet not yet sent (BUG-002).
Facing direction mismatch logged as BUG-003.
RunRate not verified as BUG-004.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 00:12:11 +02:00
Erik
e08a06ac5b feat(physics): Transition.FindTransitionalPosition core algorithm
Port FindTransitionalPosition, TransitionalInsert, FindEnvCollisions,
AdjustOffset, DoStepDown, ValidateTransition from transition_pseudocode.md.
Outdoor terrain collision with step-down ground contact. Indoor BSP and
object collision deferred to subsequent tasks.

Also adds PhysicsEngine.SampleTerrainZ() which dispatches the terrain Z
query to the right registered landblock by world-space XY position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:52:45 +02:00
Erik
9ea8ae5191 feat(physics): Transition system data structures
SpherePath, CollisionInfo, ObjectInfo, TransitionState, PhysicsGlobals.
Types match the pseudocode from transition_pseudocode.md, faithful to
decompiled CTransition + ACE Transition.cs naming.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:44:55 +02:00
Erik
874d267117 feat(physics): PhysicsDataCache + BSP sphere query
Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming.
BSPQuery.SphereIntersectsPoly traverses the tree for collision detection.
Ported from decompiled FUN_00539270, cross-ref ACE BSPNode.sphere_intersects_poly.

- PhysicsDataCache: thread-safe ConcurrentDictionary-backed cache of GfxObjPhysics
  (BSP tree + polygon dict + vertex array) and SetupPhysics (capsule dimensions).
  CacheGfxObj/CacheSetup are idempotent — safe to call at every dat load site.
- BSPQuery.SphereIntersectsPoly: recursive BSP descent with bounding-sphere broad
  phase, leaf polygon test via existing CollisionPrimitives.SphereIntersectsPoly
  (FUN_00539500), and splitting-plane classification for internal nodes.
- GameWindow: _physicsDataCache populated at all GfxObj/Setup dat load sites
  (streaming worker path, live-spawn path, ApplyLoadedTerrain render-thread path).
- 6 new unit tests covering null node, bounding-sphere miss, leaf hit, no-contact,
  internal node recursion, and empty cache behaviour. All 447 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:28:39 +02:00
Erik
5cb14da714 feat(physics): PlayerWeenie with retail Run/Jump formulas
Implements IWeenieObject with GetRunRate and GetJumpHeight from
decompiled client, cross-referenced against ACE MovementSystem.
Default skills (Run=200, Jump=100) used until skill parsing ships.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:15:25 +02:00
Erik
a722c29759 feat(physics): re-enable indoor transitions with containment validation
Sprint 2 of the audit remediation plan.

Re-enables the outdoor→indoor portal transition in PhysicsEngine with
an added containment check: after detecting a portal plane crossing,
verify the target cell's floor polygon actually covers the candidate
position AND the floor Z is within step height of the player's Z.

This prevents the wall-bounce bug (where portal planes on upper
floors captured outdoor positions) while allowing genuine doorway
transitions. Without full CellBSP, the SampleFloorZ + Z-proximity
check is the best available approximation per the indoor transition
research (docs/research/acclient_indoor_transitions_pseudocode.md).

Source: ACE EnvCell.find_transit_cells validates via
sphere_intersects_cell in the target cell's local space. Our
SampleFloorZ + Z check is the equivalent without BSP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:05:03 +02:00
Erik
0335e317d2 fix(anim): remove frame swap — cursor now traverses all frames in reverse
ROOT CAUSE of "twitching" / stuck-on-frame-0 for reverse animations
(TurnRight with negative dat framerate, StrafeRight, etc.):

The frame swap (StartFrame↔EndFrame for negative speed) made EndFrame=0,
and GetStartFramePosition returned (0+1)-eps = 0.999. The cursor
oscillated between 0.0 and 0.999 — floor() of anything in [0,1) is
always 0, so only frame 0 ever rendered.

Fix: DON'T swap. Keep StartFrame=0, EndFrame=N-1 regardless of speed
sign. GetStartFramePosition for negative speed returns (N-1+1)-eps ≈ N,
so the cursor starts near the high end and counts down through ALL
frames. The Advance loop's reverse boundary check uses StartFrame (the
low value) correctly without the swap.

Also strips diagnostic logging from AnimationSequencer and GameWindow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:15:27 +02:00
Erik
78aef6d575 refactor(anim): rewrite AnimationSequencer as faithful decompiled-client port
Complete ground-up rewrite of AnimationSequencer.cs using the retail AC client
pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct
translation guide. Every key algorithmic difference from the previous patched
implementation is addressed:

1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail
   client binary. Previously float, which accumulated rounding error over long
   sessions.

2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time:
   negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN
   from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout.

3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both
   forward and reverse; boundary detection uses EndFrame as the lower bound for
   reverse playback (matching the post-swap field semantics); remainder time
   propagates correctly across node boundaries for large dt values.

4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0)
   formulas are now correct: negative speed starts at (EndFrame+1)-epsilon,
   ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon.

5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the
   linked list is exhausted, matching the retail loop-forever semantics.

6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and
   correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed),
   WalkBackward→WalkForward (negate×0.65 BackwardsFactor).

7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the
   existing implementation is correct.

AnimationSequencerTests grows from 9 to 17 tests:
- Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1
- Reverse frame position decreases (not increases) over time
- Reverse wrap at start boundary recovers and loops
- advance_to_next_animation: link node drains then enters cycle
- Cycle loops repeatedly without crash or position drift

All 431 tests green (109 net + 322 core).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 12:59:32 +02:00
Erik
ca7ae45518 fix(anim): handle reversed frame range in BuildBlendedFrame
Backward-playback nodes (TurnLeft, SideStepLeft) have LowFrame >
HighFrame after multiply_framerate swaps them. Math.Clamp(x, 19, 0)
throws because min > max.

Fix: compute rangeLo/rangeHi from Min/Max of Low/High, use those
for clamping. Also step nextIdx in the playback direction (forward
+1, backward -1) instead of always +1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:31:32 +02:00
Erik
67b51a3e6f fix(anim): implement adjust_motion — TurnLeft/SideStepLeft play backward
ROOT CAUSE FIX for missing left-side animations.

The AC client's MotionTable has NO cycles for TurnLeft (0x000E),
SideStepLeft (0x0010), or WalkBackward (0x0006). The real client
calls adjust_motion() which remaps these to their right-side
equivalents with NEGATIVE speed before looking up the cycle. Then
multiply_framerate() swaps LowFrame↔HighFrame so the animation
plays backward.

Source: ACE MotionInterp.cs:394-428, decompiled FUN_005267E0.

Changes:
- AnimationSequencer.SetCycle: adds adjust_motion block that remaps
  left→right with speed *= -1 (TurnLeft, SideStepLeft) or
  speed *= -0.65 (WalkBackward = BackwardsFactor)
- LoadAnimNode: when framerate < 0, swaps Low↔High (matching the
  decompiled multiply_framerate)
- GameWindow.UpdatePlayerAnimation: passes original animCommand to
  SetCycle (sequencer handles remapping internally), keeps legacy
  fallback for non-sequencer entities

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:17:26 +02:00
Erik
f48f2745c4 feat(anim): AnimationSequencer with transition links + retail slerp
Port the animation playback engine from the decompiled retail client
into AcDream.Core.Physics.AnimationSequencer.

## What this adds

**AnimationSequencer** (src/AcDream.Core/Physics/AnimationSequencer.cs):

- Frame advancer: `frameNum += framerate * dt`, bounds-checks against
  AnimData.HighFrame/LowFrame (with sentinel resolution for HighFrame=-1),
  wraps at cycle boundaries. Matches ACE's `Sequence.update_internal`.

- Quaternion slerp (`SlerpRetailClient`): ported from decompiled
  `FUN_005360d0` (chunk_00530000.c:4799-4846):
    1. dot-product sign-flip to take the shorter arc
    2. fallback to linear blend when 1-dot <= 1e-4 (near-parallel)
    3. sin-based slerp for all other cases
    4. validate weights lie in [0,1] before using sin result (retail
       client validation step that guards degenerate inputs)

- Transition link resolution: `GetLink(style, fromMotion, toMotion)`
  mirrors ACE's `MotionTable.get_link` positive-speed path.
  DatReaderWriter layout: `Links[style<<16|(from&0xFFFFFF)]` is a
  `MotionCommandData` whose `.MotionData[toMotion]` is the transition
  `MotionData`. Link frames are prepended before the cyclic tail, so
  idle->walk plays the short transition clip then loops the walk cycle.

- `IAnimationLoader` / `DatCollectionLoader`: thin abstraction so the
  sequencer is testable offline without opening dat files.

- Public API: `SetCycle(style, motion, speedMod)` + `Advance(dt)`
  returning `IReadOnlyList<PartTransform>` (Origin+Orientation per part).

**AnimationSequencerTests** (tests/...Physics/AnimationSequencerTests.cs):
14 tests, all offline, covering slerp math, frame wrap, transition link
prepend, no-link direct switch, same-motion fast path, reset.

317 tests green, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:22:42 +02:00
Erik
e3f8f95dfc feat(core): port decompiled AC client MotionInterpreter — 10 methods, 45 tests
C# port of CMotionInterp from chunk_00520000.c — the AC client's
motion state machine that controls walk/run/jump/turn.

Methods ported:
- PerformMovement: top-level 5-case dispatcher
- DoMotion: process raw motion command from packet
- StopCompletely: reset to Ready (0x41000003)
- get_state_velocity: compute velocity for current interpreted state
- apply_current_movement: push velocity to PhysicsBody
- jump: initiate jump (validate + set extent + leave ground)
- get_jump_v_z: vertical jump velocity (delegates to WeenieObj)
- get_leave_ground_velocity: full 3D launch vector
- jump_is_allowed: requires Gravity + Contact + OnWalkable
- contact_allows_move: slope angle + state checks

Supporting types: MotionCommand constants, MovementType enum,
WeenieError enum, RawMotionState/InterpretedMotionState structs,
IWeenieObject interface.

412 total tests (303 core + 109 net). 45 new MotionInterpreter tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:00:39 +02:00
Erik
6a5d8c1580 feat(core): port decompiled AC client physics — CollisionPrimitives + PhysicsBody
Two major C# ports from the decompiled retail AC client (acclient.exe):

1. CollisionPrimitives (9 functions, 26 tests):
   - SphereIntersectsRay, RayPlaneIntersect, CalcNormal
   - SphereIntersectsPoly, FindTimeOfCollision
   - HitsWalkable, FindWalkableCollision
   - SlideSphere, LandOnSphere
   Ported from chunk_00530000.c functions FUN_005384e0 through FUN_0053a230.
   Cross-referenced against ACE's Physics/ C# port for algorithm verification.

2. PhysicsBody (7 methods, 31 tests):
   - update_object (top-level per-frame, sub-stepped at MaxQuantum=0.1)
   - UpdatePhysicsInternal (Euler: pos += v*dt + 0.5*a*dt²)
   - calc_acceleration (gravity=-9.8 when HasGravity)
   - set_velocity (clamp to MaxVelocity=50)
   - set_local_velocity (body→world via quaternion)
   - set_on_walkable, calc_friction (ground normal + pow decay)
   Ported from chunk_00510000.c/chunk_00500000.c.
   Struct layout confirmed against ACE PhysicsObj field offsets.

367 total tests green (258 core + 109 net). 57 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:54:51 +02:00