Commit graph

30 commits

Author SHA1 Message Date
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
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
3ae7f5e7ae fix(physics): correct terrain Z sampling triangle layout in TerrainSurface
The SampleZ method had the two triangle-boundary conditions swapped relative
to what the mesh index buffer actually renders, causing the physics Z to
sample from the wrong triangle on roughly half of all terrain cells. The
error could be up to 7.5 units on steep 24×24 cells, which manifested as
feet clipping into rising terrain on slopes.

Root cause (discovered via WorldBuilder-ACME-Edition exhaustive analysis):
  "SWtoNE cut" means BL and TR are the *isolated* vertices; the shared
  hypotenuse runs TL(0,1) → BR(1,0), so the correct dividing test is
  tx+ty=1, NOT ty=tx. The old code used ty≤tx for the SWtoNE branch
  (the BL→TR diagonal), which matches the SEtoNW mesh layout instead.

Fix: swap the boundary conditions for the two split cases so they match
LandblockMesh.cs's actual index buffer layout:
  SWtoNE: tx+ty ≤ 1 → BL+BR+TL, else TR+TL+BR
  SEtoNW: ty ≤ tx  → BL+BR+TR, else BL+TR+TL

Verified by running all three formulas (acdream, ACME GetHeight, ACME
HeightSampler) against the mesh index buffer at 50 interior points across
both split types: fixed version matches 0 errors, old version had 50.
All 283 tests still pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 22:25:32 +02:00
Erik
78d43a0914 fix(core+app): slope gradient compensation for feet clipping
The triangle-aware Z sampling from the previous commit produces exact
terrain-surface Z values, but a point-sampled Z on a tilted surface
places the character's center on the surface while their feet (which
extend horizontally) clip into the rising terrain ahead/behind.

Fix: in PlayerMovementController, sample Z 1 unit ahead in the walk
direction and add 40% of the gradient as an upward bias. This
compensates for the character's collision cylinder radius on slopes
while producing zero bias on flat ground. The bias is applied in the
movement controller (gameplay concern) not in TerrainSurface.SampleZ
(which stays exact for physics/tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:13:16 +02:00
Erik
c5de445e5c fix(core): AC2D render split formula + triangle-aware Z sampling
Two fundamental terrain fixes based on the AC2D + holtburger deep dive:

1. Terrain split formula: replaced WorldBuilder's physics-path formula
   (214614067/1813693831) with AC2D's render-path formula (0x0CCAC033,
   0x421BE3BD, 0x6C1AC587, 0x519B8F25). The two produce different splits
   for some cells. Since the render mesh uses this formula, the physics
   Z sampler must match it to avoid misalignment on slopes.

2. Triangle-aware Z: replaced bilinear interpolation in TerrainSurface
   with per-triangle barycentric interpolation. Each cell is split into
   two triangles (using the same AC2D formula). SampleZ determines which
   triangle the query point falls in, then interpolates within that
   triangle. This produces Z values that exactly match the visual terrain
   mesh — no more slope clipping.

Removes the multi-point Z sampling hack from PlayerMovementController
(no longer needed with exact triangle Z).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:08:42 +02:00
Erik
dc0341e85a fix(core): Phase B.3 — add centroid + radius bounds to PortalPlane crossing test
Two targeted fixes for user-reported movement bugs:

1. Wall bounce: PortalPlane.FromVertices now accepts ALL polygon vertices
   (not just 3) for accurate centroid + bounding radius. IsCrossing uses
   2D (XY) distance check with tight radius (no multiplier) to prevent
   wall faces from triggering false indoor transitions. Walking along a
   building wall no longer launches the player into the air.

2. Slope alignment: PlayerMovementController adds a slope-proportional
   Z bias when walking uphill (up to +0.8 on steep slopes, grounded
   only). Prevents feet from sinking into the visual terrain mesh on
   slopes where the physics sample point lags the render surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:08:46 +02:00
Erik
9dbb2cbd5c fix(core): Phase B.3 — add centroid + radius bounds to PortalPlane crossing test
Portal planes are infinite — IsCrossing only checked if positions
were on opposite sides of the plane, without verifying the crossing
point was near the actual portal opening. Walking 50m from a
building but crossing the same plane extension triggered false indoor
transitions, sinking the player into underground cell floors.

Fix: FromVertices now computes centroid (average of 3 vertices) and
bounding radius (max vertex distance + 2 unit padding). IsCrossing
rejects crossings where both positions are further than 2×radius
from the centroid. Only nearby crossings (within doorway range)
trigger a transition.

Also fixes jump-not-landing: the false portal transitions were
producing wrong resolvedGroundZ values during the jump arc, making
the landing check (candidateZ <= resolvedGroundZ) never true.

283 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:39:50 +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
3b2f56c531 fix(core): Phase B.2 MVP — disable outdoor→indoor transition entirely
Three attempts at guarding the indoor transition (cellId mask,
Z threshold, terrainZ comparison) all failed because CellSurface
floor polygons are too aggressive — building footprints, roofs, and
upper floors all have PhysicsPolygons that cover wide XY areas at
various Z levels, and ANY outdoor position near a building matches
a cell floor. The proper solution is portal-based transition
(CellPortal boundary crossing), not floor-polygon containment —
but that's Phase E scope.

For the B.2 MVP: outdoor players NEVER transition to indoor cells.
The else-if branch is compiled out with `if (false)`. Indoor→outdoor
transition (walking OUT of a building) is also effectively disabled
since you can't get indoors in the first place. Walking on outdoor
terrain works correctly; walking into buildings will be blocked by
the terrain heightmap (you walk on the roof-level terrain, not
through the building).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:08:36 +02:00
Erik
5280950806 fix(core): Phase B.2 — require indoor floor below terrain for outdoor→indoor transition
Previous cellId-mask fix was necessary but insufficient: the engine
correctly identified the player as outdoor, but then immediately
transitioned to an indoor cell because a CellSurface floor polygon
covered the player's XY at a Z within stepUpHeight. The floor
polygon was a roof or upper floor of a nearby building that happens
to sit at terrain level — not a walkable indoor floor the player
should snap to.

Fix: outdoor→indoor transition now requires bestCellZ < terrainZ - 1.
A genuine indoor transition is into a cell whose floor is BELOW the
terrain surface (basement, ground floor of elevated building). Cells
at or above terrain Z are roofs/upper floors viewed from outside and
must not capture the player.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:05:54 +02:00
Erik
05d835ff33 fix(core): Phase B.2 — mask cellId to low 16 bits in PhysicsEngine.Resolve
ROOT CAUSE of the fall-through-ground bug. The currentlyIndoor check
compared the FULL 32-bit cell ID (0xA9B40001, includes landblock
prefix) against 0x0100. Every outdoor cell ID with a landblock
prefix is >= 0x0100, so the engine ALWAYS took the "stay indoors"
path, snapping the player to the nearest EnvCell floor at Z=66
instead of the outdoor terrain at Z=94.

ACE confirmed the bug: "AddWorldObjectInternal: couldn't spawn
+Acdream at 0xA9B40121 [84.2 37.7 66.0]" — we were sending the
server an indoor cell ID with a below-terrain Z position.

Fix: mask cellId with 0xFFFF before the indoor check (outdoor
cells are 0x0001–0x0040; indoor are 0x0100+; the high 16 bits
are the landblock prefix and must be stripped). Also mask the
returned targetCellId from CellSurface (which carries the full
EnvCell dat id) to just the cell index.

265 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:04:24 +02:00
Erik
97c17c5bc3 fix(app): Phase B.2 — use server position directly, fix yaw wrap + turn spam
Three more fixes from the diagnostic dump:

1. Initial position: PhysicsEngine.Resolve was mapping the player
   into an indoor EnvCell (foundry at Z=66) when they're standing
   on outdoor terrain at Z=93+. The cell-containment check was too
   aggressive for initial placement. Now uses the server-sent
   position directly — the server already gave us a valid position.

2. Yaw unbounded: mouse delta accumulated without wrapping, growing
   to 24+ radians. Now wraps to [-PI, PI] after every turn.

3. Turn command spam: MouseDeltaX > 0.5 threshold was too low for
   raw pixel deltas. Any mouse jitter triggered turnCmd flips every
   frame → stateChanged=True → MoveToState flood to the server.
   Mouse turning now only affects yaw directly; turn COMMANDS only
   come from A/D keyboard (matching retail client behavior where
   mouse-look doesn't generate a TurnRight/TurnLeft command).

265 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:58:25 +02:00
Erik
88d446d11d feat(core): Phase B.3 — PhysicsEngine (top-level collision resolver)
Combines TerrainSurface + CellSurface into a single Resolve() API
that handles outdoor terrain walking, indoor floor walking,
outdoor<->indoor cell transitions, step-height enforcement, and
ground detection.

Step-height blocks upward Z deltas exceeding the limit (walls,
cliffs); downhill movement is always accepted. Indoor transitions
pick the cell whose floor Z is closest to the entity's current Z
(handles multi-story buildings). Reports IsOnGround=false when
no landblock or surface covers the entity's position (gravity
applied by the caller).

One API mismatch fixed vs plan: plan encoded the upper 16 landblock
bits into the returned cell ID, but the tests assert the raw cell ID
(0x0100, <0x0100) — so Resolve returns targetCellId directly.

6 new tests covering flat terrain, slopes, step-height rejection,
indoor entry/exit, and void detection. 243 total, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:54:28 +02:00
Erik
19aa8ce5d0 feat(core): Phase B.3 — TerrainSurface (outdoor heightmap Z + cell ID)
Extracts the bilinear heightmap interpolation from GameWindow's
inlined SampleTerrainZ into a reusable class. Also adds outdoor
cell ID computation (8×8 grid of 24-unit cells, 0x0001..0x0040).

First component of the physics collision engine.

6 new tests, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:51:54 +02:00
Erik
520589911b feat(core): Phase B.3 — CellSurface (indoor floor polygon projection)
Projects an XY point onto a cell's floor polygons via brute-force
triangle iteration + barycentric Z interpolation. Fan-triangulates
quads and larger polygons. Returns null when outside all floor
surfaces. Accepts pre-transformed world-space vertex positions so
the caller handles EnvCell coordinate transforms.

Second component of the physics collision engine.

4 new tests, all green.

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