Commit graph

53 commits

Author SHA1 Message Date
Erik
ec59a08db5 fix(physics): #42 skip self in FindObjCollisions — airborne XY drift
Root cause confirmed via two-run diagnostic and the named-retail decomp:
the airborne sweep was colliding with the moving entity's OWN ShadowEntry
because FindObjCollisions had no self-skip filter. Live entities (local
player, remotes) register a Cylinder in ShadowObjectRegistry on spawn
(GameWindow.cs:2545) and UpdatePosition tracks its world position each
tick, so the moving sphere's own cylinder is always at the body's
position. Without a gate, CylinderCollision sees the sphere overlapping
its own cylinder volume and slides the sphere ~1m horizontally on every
frame the path produces non-zero motion.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:56:42 +02:00
Erik
08fbbef3c4 feat(physics): PositionManager combiner class + 6 unit tests (L.3.2)
Pure-function ComputeOffset(dt, pos, seqVel, ori, interp, maxSpeed) →
Vector3. Combines animation root motion (seqVel × dt rotated by body
orientation) with InterpolationManager.AdjustOffset world-space
correction. Mirrors retail CPhysicsObj::UpdateObjectInternal
(acclient @ 0x00513730).

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:13:02 +02:00
Erik
5b26d28b08 test(physics): MyRunRate fallback test for GetMaxSpeed (L.3.1 Task 2 polish)
Code-quality review on commit 9c5634a flagged that the existing 4
GetMaxSpeed tests didn't cover the case where WeenieObj is null and
RunForward must fall back to MyRunRate. Without this test, a
regression that hardcoded the fallback to 1.0f would silently pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:20:39 +02:00
Erik
9c5634af17 feat(physics): MotionInterpreter.GetMaxSpeed for InterpolationManager (L.3.1 Task 2)
Ports retail's CMotionInterp::get_max_speed (0x00527cb0). Returns
motion-table-derived max speed (m/s) for InterpretedState.ForwardCommand:
- RunForward:   RunAnimSpeed (4.0) × (InqRunRate ?? MyRunRate)
- WalkForward:  WalkAnimSpeed (3.12)
- WalkBackward: WalkAnimSpeed × 0.65 (BackwardsFactor from adjust_motion @ 0x00528010)
- otherwise:    0

Decomp note: Binary Ninja emits a spurious void return for x87 FPU-returning
functions; the actual float return is confirmed by both callers
(StickyManager::adjust_offset @ 0x00555430,
InterpolationManager::AdjustOffset @ 0x00555d52) which multiply the result
by 2.0 to produce a catch-up speed in m/s. The per-command switch is
consistent with get_state_velocity (0x00527d50) which uses the same constants.

Used by InterpolationManager.AdjustOffset in Task 5 as 2 × GetMaxSpeed().
Until Task 5 wires it, the method is unused — covered by 4 unit tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:16:38 +02:00
Erik
927636ec77 fix(physics): InterpolationManager review findings (L.3.1 Task 1 polish)
Addresses code-quality review findings on commit f43f168:

C-1: Stall detection re-implemented to match retail (acclient lines
353071-353275). Tracks _progressQuantum (sum of step values per window)
+ _distanceAtWindowStart (set at window start). Primary check:
cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20m absolute).
Secondary check: cumulative_progress / _progressQuantum < 0.30.
Either failing increments fail counter; blip-to-tail at >3 consecutive
fails (already correct).

C-2: Renamed StallFailCountForBlip -> StallFailCountThreshold with
clearer XML doc explaining the > vs >= semantics (blip fires when fail
count EXCEEDS the threshold, i.e. on the 4th consecutive failed window).

I-1: _haveBaselineDistance sentinel prevents first-window false
positive that was triggering spurious fails on every new motion sequence
(old code defaulted _distanceAtWindowStart to 0, making cumulative
progress always negative on frame 5).

I-3: dt <= 0 || NaN guard at AdjustOffset entry prevents NaN
propagation into PhysicsBody.Position.

I-4: Internal field renames for clarity:
  _failFrameCounter        -> _framesSinceLastStallCheck
  _failDistanceLastCheck   -> merged into _distanceAtWindowStart

I-5: Added internal Count property + InternalsVisibleTo (via
AssemblyAttribute in .csproj) so Enqueue_DropsOldestWhenAtCap20
actually verifies cap enforcement. Added assertion that head is the
second-enqueued position after overflow.

3 new tests (AdjustOffset_FirstWindow_DoesNotFalseFail,
AdjustOffset_DtZeroOrNegative_ReturnsZero,
Enqueue_AtCap20_HeadIsSecondOriginal), 16 total. All green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:10:23 +02:00
Erik
f43f168916 feat(physics): InterpolationManager core (L.3.1 Task 1)
Pure-data class + 13 unit tests.

Ports retail's CPhysicsObj::InterpolateTo (acclient @ 0x005104F0)
and InterpolationManager::adjust_offset (@ 0x00555D30) — FIFO position-
waypoint queue (cap 20) + per-frame catch-up math walking the body
toward the head node at 2 × motion-table-max-speed (clamped, with
7.5 m/s fallback). Reach @ 0.05m. Duplicate-prune @ 0.05m.

Stall detection: every 5 frames; if progress < 30% of expected,
increment fail counter; > 3 fails → blip-to-TAIL (resolved via
decomp dive of UseTime @ 0x00555F20: tail_ is the snap target,
not head_).

Constants verified from binary at named addresses (not guesses):
MAX_INTERPOLATED_VELOCITY_MOD=2.0, MAX_INTERPOLATED_VELOCITY=7.5,
MIN_DISTANCE_TO_REACH_POSITION=0.20, DESIRED_DISTANCE=0.05.

Composed into RemoteMotion in subsequent task; not yet used.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:00:17 +02:00
Erik
b1af56eb19 fix(physics): L.4 — steep airborne hits slide-tangent (interim, deviates from retail)
Phase L.4 closes the "stuck in falling animation on a steep roof" bug
the user reported on 2026-04-30 ("I jump up, I land on it. It should not
even let me land, should just slide with a falling animation"). After
this commit the body no longer sticks to a steep roof when jumping
into it — it slides along the slope while keeping the falling animation.

Two pieces:

1. BSPQuery Path 6 steep-poly slide
   When an airborne sphere hits a polygon whose world normal Z is below
   FloorZ (≈ 0.6642, slope > ~49°), the previous flow was:
   Path 6 SetCollide → Path 4 set_walkable → ContactPlane committed →
   body "lands" on the steep poly with Contact bit + falling animation.
   This left the player stuck mid-slope because OnWalkable was cleared
   but Contact stayed set.

   The new branch detects the steep normal in Path 6 BEFORE SetCollide
   is called. Instead of entering the landing path, it removes the
   into-wall component of the move (project onto the steep face), sets
   CollisionNormal + SlidingNormal, and returns Slid. Same shape as
   Path 5's step-up fallback and CylinderCollision. The resolver retries;
   the sphere is now outside the poly; FindCollisions returns OK;
   ValidateTransition commits the slid position. ContactPlane is never
   set, so the body stays airborne with falling animation.

2. PlayerMovementController L.3a-bounce carve-out + Inelastic stop
   Re-enables the velocity-reflection bounce when the contact normal is
   upward-facing but steeper than walkable (0 < N.Z < FloorZ). The base
   L.3a rule suppresses bounce on landing transitions to avoid micro-
   bounce on flat terrain; that suppression also stuck the player to
   too-steep roofs they shouldn't land on. This carve-out re-enables
   the reflection specifically for the steep upward case.

Also lands related L.2c precipice / edge-slide work that was in flight:

- TransitionTypes EdgeSlideAfterStepDownFailed: walkable-poly-steep
  cliff route + steep-ContactPlane cliff route ordering, so that
  CliffSlide fires when the stored walkable polygon itself is too
  steep (Path 4 had previously accepted it as a "landing" via the
  permissive LandingZ threshold).
- CliffSlide reference-normal selection: prefer LastWalkable, fall back
  to LastKnownContactPlane only when walkable, else use world-up. This
  prevents the cross(steepN, steepN) = 0 degenerate case that left the
  cliff slide as a no-op when both current and last-known were steep.
- Phase 2 / step-down branch / edge-slide branch / cliff-slide
  diagnostic helpers gated on ACDREAM_DUMP_EDGE_SLIDE / ACDREAM_DUMP_STEEP_ROOF.
- Two new airborne-mover regression tests in BSPStepUpTests +
  PhysicsEngineTests covering wall-slide and edge tangent motion.

DEVIATION FROM RETAIL — DOCUMENTED FOR FOLLOW-UP

The Path 6 steep slide is NOT what retail does. Retail's flow on the
same hit is:

  Path 6 SetCollide (no steep check) → Path 4 find_walkable returns
  nothing for steep → Phase 3 reset path: restore_check_pos +
  kill_velocity → return COLLIDED → validate_transition reverts CheckPos
  to CurPos and forces OK.

Net retail behavior: position reverts to pre-failed-move (typically
just below the roof in the common jump-up case), velocity zeroed,
gravity rebuilds Z next frame, body falls back down naturally with
the falling animation. The "freeze" framing I used earlier was wrong;
in the typical case retail just bounces the body off and lets gravity
take over.

Strict retail behavior would match the user's intent better in the
common case AND avoid the bounce-energy-accumulation we saw with the
slide-tangent approach (V grew to ~50 m/s in continuous-contact frames).
However, retail's behavior degenerates in the edge case of an overhead
landing onto a steep slope (body would freeze mid-air above the roof).

This commit ships the slide-tangent fix as an interim "much better"
state per user verification on 2026-04-30. Follow-up work to match
retail strictly: revert Path 6 steep-slide, audit Phase 3 reset to
ensure kill_velocity (matching OBJECTINFO::kill_velocity ->
CPhysicsObj::set_velocity({0,0,0}, 0)) actually fires, and re-test.

Refs:
  - acclient_2013_pseudo_c.txt:323784-323821 (Path 6 SetCollide)
  - acclient_2013_pseudo_c.txt:273191-273239 (Phase 3 reset path)
  - acclient_2013_pseudo_c.txt:272563-272596 (validate_transition revert)
  - acclient_2013_pseudo_c.txt:274467-274475 (kill_velocity)
  - acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions bounce)

Tests: 833/833 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:22:07 +02:00
Erik
261322b48e fix(physics): #32 L.2c precipice edge-slide context
Port the first retail precipice-slide slice from named retail/ACE: terrain and BSP walkable hits now preserve polygon vertices, failed step-down edges back-probe to rediscover the walkable polygon, and edge-slide can run precipice/cliff slide instead of only hard-stopping.

Adds pseudocode anchors plus regression coverage for terrain polygon context and loaded-terrain boundary edge-slide.

Co-authored-by: Codex <codex@openai.com>
2026-04-30 08:04:37 +02:00
Erik
1ec40f2a4f fix(physics): #32 L.2c wire edge-slide movement flag 2026-04-30 07:40:43 +02:00
Erik
9fea9b13ad fix(physics): #31 update outdoor cell id during transition movement 2026-04-29 22:00:30 +02:00
Erik
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
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
b96b680a20 fix(anim): Phase L.1c route creature actions and despawns
Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn.

Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence.

Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
2026-04-28 19:21:02 +02:00
Erik
460f95cb42 fix(anim): Phase L.1b route motion commands 2026-04-28 10:46:22 +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
f844613295 test(anim): CurrentOmega — speedMod scaling for TurnRight cycles
Fills in the test coverage gap for the rotational side of the
sequence-wide physics. Symmetric to the existing
CurrentVelocity_ScalesWithSpeedMod test: at speedMod=2.0 a
MotionData.Omega of (0,0,1) surfaces as (0,0,2). This is what the
omega rotation-integrator in TickAnimations reads each tick. 660
tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:48:01 +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
6e589d3b89 test(anim): PlayAction conformance — Action, Modifier, Emote
Four new tests covering the PlayAction routing paths that the new
UpdateMotion Commands[] handler relies on:

- PlayAction_Action_ResolvesFromLinksDict — a ThrustMed attack in
  SwordCombat stance resolves via Links[(SwordCombat, Ready)][ThrustMed]
  and its anim frames become visible after PlayAction is called.
- PlayAction_Modifier_ResolvesFromModifiersDict — Jump (0x2500003B,
  Modifier class) resolves via Modifiers[(Style, Jump)] and its anim
  plays on top of the current cycle.
- PlayAction_Emote_RoutesThroughActionBranch — Wave (0x13000087, class
  byte 0x13 = Action | ChatEmote | Mappable) goes through the Action
  branch because the Action bit is set, resolving from Links just like
  attacks. Validates the class-bit math.
- PlayAction_NoEntryInTable_IsNoOp — silent no-op when the table has
  no entry for the motion, with the queue length unchanged.

Together these lock in that the same PlayAction path correctly routes
the three major one-shot classes the Commands[] handler fans out to
NPCs and remote players. 658 tests green (was 654).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:38:01 +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
b7a9322b40 feat(anim): dead-reckoning remote entity positions
Before: remote characters stutter-hop between UpdatePosition broadcasts
(typical 100-200ms interval), looking lagging-forward during continuous
motion. The retail client hides this gap by integrating velocity forward
each tick — apply_current_movement in chunk_00520000.c L7132-L7189,
mirrored by holtburger's project_pose_by_velocity in spatial/physics.rs.

Strategy:

1. RemoteDeadReckonState per remote entity tracks the last authoritative
   server position + rotation, an EMA-smoothed observed velocity from
   position deltas, and any server-supplied HasVelocity vector.

2. OnLivePositionUpdated: on each UpdatePosition arrival, snap the entity
   to the server position, then update the dead-reckon state. The
   observed-velocity is a 50/50 EMA against the running average so a
   single jitter sample doesn't blow out the velocity.

3. TickAnimations: each tick, for every remote entity in a locomotion
   cycle, integrate Entity.Position += worldVelocity * dt. World velocity
   is pulled in priority order:
     - Sequencer's MotionData.Velocity rotated by Entity.Rotation (the
       primary source; matches MotionData's "world-space on the object"
       convention per r03 §1.3)
     - Server-supplied HasVelocity from UpdatePosition (already world-space)
     - EMA-observed position-delta velocity (fallback for NPC motion
       tables with HasVelocity=0)

4. Cap: if the predicted position drifts more than velocity ×
   DeadReckonMaxPredictSeconds (1.0s) from the last server position,
   clamp back toward the server. This prevents runaway when sequencer
   velocity and server reality disagree (e.g. server rubber-banding).

Result: remote chars now move smoothly between position updates,
matching the retail client's visual feel. When UpdatePosition arrives
the entity snaps to the authoritative position and the dead-reckon
origin resets, so there's no accumulating drift.

Tests: CurrentVelocity_ScalesWithSpeedMod — new unit test verifying
that the sequencer's CurrentVelocity accurately reflects speedMod changes
across both SetCycle's rebuild path and its rescale path. Combined with
the existing MultiplyCyclicFramerate tests, this validates the
downstream-visible velocity surface the dead-reckoner reads. 633 tests
green (was 632).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:29:56 +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
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
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
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
c7fa1d36fb feat(movement): wire server RunRate into player MotionInterpreter
Parse ForwardSpeed from UpdateMotion (0xF74C) InterpretedMotionState.
Feed server-echoed RunRate into the player's MotionInterpreter so
get_state_velocity produces the correct speed. Previously hardcoded
at 1.0 (4.0 m/s), now matches character's Run skill.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:11:49 +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
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
Erik
21fd550909 feat(physics): port 9 collision primitives from acclient.exe (chunk_00530000.c)
Adds CollisionPrimitives.cs with C# ports of FUN_005384e0 / FUN_00539500 /
FUN_00539ba0 / FUN_00539110 / FUN_00539060 / FUN_0053a230 / FUN_0053a040 /
FUN_00538eb0 / FUN_00538f50 — covering ray-sphere, sphere-poly contact,
find-time-of-collision, face-normal computation, ray-plane intersection,
walkable checks, edge-normal slide, and sphere landing.

Key findings from cross-referencing with ACE's Polygon.cs:
- The edge-perpendicular formula is cross(N, edge) (normal × edge), matching
  the retail param_1[9/10/8] order in the decompiled loops.
- find_time_of_collision uses t = (dot(origin,N)+D) / dot(dir,N); the sign
  is negative when approaching from above — contact = origin − dir*t.
- land_on_sphere only succeeds when the sphere centre is within one radius of
  the plane (dist < r), which is the "settling onto ground" scenario.

26 new tests green; full suite 367/367 green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 23:53:47 +02:00
Erik
8252523b8b feat(core): Phase B.3 — CellPortal-based indoor/outdoor transitions in PhysicsEngine
Replace the disabled if(false) outdoor→indoor branch with real portal-plane
crossing logic. LandblockPhysics now carries IReadOnlyList<PortalPlane> Portals
(populated at load time; GameWindow passes Array.Empty for now until Task 3).

Resolve logic:
- Outdoor player: tests all portals where TargetCellId==0xFFFF (outside-facing);
  crossing enters the portal's OwnerCellId.
- Indoor player: tests portals where OwnerCellId==currentCell; crossing to
  TargetCellId==0xFFFF exits to terrain, otherwise transitions room-to-room.
- Landblock boundary crossing: unchanged — candidatePos landblock lookup already
  picks the adjacent block's terrain naturally.

Tests: renamed disabled test → Resolve_OutdoorThroughPortal_TransitionsToIndoor;
added Resolve_IndoorThroughExitPortal_TransitionsToOutdoor and
Resolve_LandblockBoundary_PicksAdjacentTerrain. 274 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:22:55 +02:00
Erik
cb46d892d5 feat(core): Phase B.3 — PortalPlane (plane math + crossing detection)
Adds the foundational portal-plane record for cell transition detection.
PortalPlane.FromVertices computes a normalised plane from 3 coplanar
polygon vertices via cross product + dot product; IsCrossing tests whether
a movement vector straddles the plane (strictly negative dot-product
product — exact-on-plane position returns false as specified).

4 new unit tests: normal construction, opposite-side crossing, same-side
no-crossing, start-on-plane no-crossing.  All 269 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:17:48 +02:00
Erik
228eecbb31 chore(app): Phase B.2 — strip diagnostic logging + fix indoor-transition test
Removes all [PLAYER], [PLAYER-INIT], [PLAYER-ANIM] diagnostic dump
lines now that walking, camera, and animation are verified working.

Updates PhysicsEngineTests.Resolve_EnterIndoorCell to match the new
behavior (outdoor→indoor transition disabled in the B.2 MVP): the
test now asserts the player stays outdoor at terrain Z instead of
transitioning to the indoor cell.

265 tests green.

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